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

Interop Type Mapping #110691

Open
AaronRobinsonMSFT opened this issue Dec 13, 2024 · 54 comments
Open

Interop Type Mapping #110691

AaronRobinsonMSFT opened this issue Dec 13, 2024 · 54 comments

Comments

@AaronRobinsonMSFT
Copy link
Member

AaronRobinsonMSFT commented Dec 13, 2024

Interop Type Mapping

Background

When interop between languages/platforms involves the projection of types, some kind of type mapping logic must often exist. This mapping mechanism is used to determine what .NET type should be used to project a type from language X and vice versa.

The most common mechanism for this is the generation of a large look-up table at build time, which is then injected into the application or Assembly. If injected into the Assembly, there is typically some registration mechanism for the mapping data. Additional modifications and optimizations can be applied based on the user experience or scenarios constraints (that is, build time, execution environment limitations, etc).

At present, there are at least three (3) bespoke mechanisms for this in the .NET ecosystem:

Related issue(s):

Proposal

The .NET ecosystem should provide an official API and process for handling type mapping in interop scenarios.

Priorties

  1. Trimmer friendly - AOT compatible.
  2. Usable from both managed and unmanaged environments.
  3. Low impact to application start-up and/or Assembly load.
  4. Be composable - handle multiple type mappings.

The below .NET APIs represents only part of the feature. The complete scenario would involve additional steps and tooling.

Provided by BCL (that is, NetCoreApp)

namespace System.Runtime.InteropServices;

/// <summary>
/// Base interface for target type universe.
/// </summary>
public interface ITypeMapUniverse { }

/// <summary>
/// Type mapping between a string and a type.
/// </summary>
/// <typeparam name="TTypeUniverse">Type universe</typeparam>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public sealed class TypeMapAttribute<TTypeUniverse> : Attribute
    where TTypeUniverse : ITypeMapUniverse
{
    /// <summary>
    /// Create a mapping between a value and a <see cref="System.Type"/>.
    /// </summary>
    /// <param name="value">String representation of key</param>
    /// <param name="target">Type value</param>
    /// <remarks>
    /// This mapping is unconditionally inserted into the type map.
    /// </remarks>
    public TypeMapAttribute(string value, Type target)
    { }

    /// <summary>
    /// Create a mapping between a value and a <see cref="System.Type"/>.
    /// </summary>
    /// <param name="value">String representation of key</param>
    /// <param name="target">Type value</param>
    /// <param name="trimTarget">Type used by Trimmer to determine type map inclusion.</param>
    /// <remarks>
    /// This mapping is only included in the type map if the Trimmer observes a type check
    /// using the <see cref="System.Type"/> represented by <paramref name="trimTarget"/>.
    /// </remarks>
    [RequiresUnreferencedCode("Interop types may be removed by trimming")]
    public TypeMapAttribute(string value, Type target, Type trimTarget)
    { }
}

/// <summary>
/// Declare an assembly that should be inspected during type map building.
/// </summary>
/// <typeparam name="TTypeUniverse">Type universe</typeparam>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public sealed class TypeMapAssemblyTargetAttribute<TTypeUniverse> : Attribute
    where TTypeUniverse : ITypeMapUniverse
{
    /// <summary>
    /// Provide the assembly to look for type mapping attributes.
    /// </summary>
    /// <param name="assemblyName">Assembly to reference</param>
    public TypeMapAssemblyTargetAttribute(string assemblyName)
    { }
}

/// <summary>
/// Create a type association between a type and its proxy.
/// </summary>
/// <typeparam name="TTypeUniverse">Type universe</typeparam>
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
public sealed class TypeMapAssociationAttribute<TTypeUniverse> : Attribute
    where TTypeUniverse : ITypeMapUniverse
{
    /// <summary>
    /// Create an association between two types in the type map.
    /// </summary>
    /// <param name="source">Target type.</param>
    /// <param name="proxy">Type to associated with <paramref name="source"/>.</param>
    /// <remarks>
    /// This mapping will only exist in the type map if the Trimmer observes
    /// an allocation using the <see cref="System.Type"/> represented by <paramref name="source"/>.
    /// </remarks>
    public TypeMapAssociationAttribute(Type source, Type proxy)
    { }
}

/// <summary>
/// Entry type for interop type mapping logic.
/// </summary>
public static class TypeMapping
{
    /// <summary>
    /// Returns the External type type map generated for the current application.
    /// </summary>
    /// <typeparam name="TTypeUniverse">Type universe</typeparam>
    /// <param name="map">Requested type map</param>
    /// <returns>True if the map is returned, otherwise false.</returns>
    /// <remarks>
    /// Call sites are treated as an intrinsic by the Trimmer and implemented inline.
    /// </remarks>
    [RequiresUnreferencedCode("Interop types may be removed by trimming")]
    public static bool TryGetExternalTypeMapping<TTypeUniverse>([NotNullWhen(returnValue: true)] out IReadOnlyDictionary<string, Type>? map)
        where TTypeUniverse : ITypeMapUniverse;

    /// <summary>
    /// Returns the associated type type map generated for the current application.
    /// </summary>
    /// <typeparam name="TTypeUniverse">Type universe</typeparam>
    /// <param name="map">Requested type map</param>
    /// <returns>True if the map is returned, otherwise false.</returns>
    /// <remarks>
    /// Call sites are treated as an intrinsic by the Trimmer and implemented inline.
    /// </remarks>
    [RequiresUnreferencedCode("Interop types may be removed by trimming")]
    public static bool TryGetTypeProxyMapping<TTypeUniverse>([NotNullWhen(returnValue: true)] out IReadOnlyDictionary<Type, Type>? map)
        where TTypeUniverse : ITypeMapUniverse;
}

Given the above types the following would take place.

  1. Types involved in unmanaged-to-managed interop operations would be referenced in a
    TypeMapAttribute assembly attribute that declared the external type system name, a target
    type, and optionally a "trim-target" type use by the Trimmer to determine if the target
    type should be included in the map. If the trim-target type is used in a type check, then
    the entry will be inserted into the map. If the TypeMapAttribute constructor that doesn't
    take a trim-target is used, the entry will be inserted unconditionally.

The target type would have interop specific "capabilities" (for example, create an instance).

  1. Types used in a managed-to-unmanaged interop operation would use TypeMapAssociationAttribute
    to define a conditional link between the source and proxy type. In other words, if the
    source is kept, so is the proxy type. If Trimmer observes an explicit allocation of the source
    type, the entry will be inserted into the map.

  2. During application build, source would be generated and injected into the application
    that defines appropriate TypeMapAssemblyTargetAttribute instances. This attribute would help the
    Trimmer know other assemblies to examine for TypeMapAttribute and TypeMapAssociationAttribute
    instances. These linked assemblies could also be used in the non-Trimmed scenario whereby we
    avoid creating the map at build-time and create a dynamic map at run-time instead.

  3. The Trimmer will build two maps based on the above attributes from the application reference
    closure.

    (a) Using TypeMapAttribute a map from string to target Type. If a trim-target Type
    was provided, the Trimmer will determine if it is used in a type check. If it was used in
    a type check, the mapping will be included. If the trim-target type is not provided, the mapping
    will be included unconditionally.

    (b) Using TypeMapAssociationAttribute a map from Type to Type (source to proxy).
    The map will only contain an entry if the Trimmer determines the source type was explicitly
    allocated.

    Note Conflicting key/value mappings in the same type universe would be reconciled by the
    application re-defining the mapping entry that will be in the respective map. If a conflict
    is still present, the build should fail.

    Note The emitted map format is a readonly binary blob that will be stored in the application
    assembly. The format of the binary blob is an implementation detail that will be passed to
    an internal type contained with CoreLib.

  4. The Trimmer will consider calls to TypeMapping.GetExternalTypeMapping<> and
    TypeMapping.GetTypeProxyMapping<> as intrinsic operations and replaced inline with the appropriate
    map instantiation (for example, Java via JavaTypeUniverse).

    IReadOnlyDictionary<string, Type> externalToType;
    TypeMapping.TryGetExternalTypeMapping<JavaTypeUniverse>(out externalToType);
    // The above will be replaced by the Trimmer with an instantiation of the appropriate map.
    // The "StaticTypeMap" being an internal type and an implementation detail provided by CoreLib.
    // Replaced with:
    //     IReadOnlyDictionary<string, Type> externalToType;
    //     externalToType = new StaticTypeMap(s_javaTypeUniverseMapBlob);

Example usage

Provided by .NET for Android runtime

// Existing type
// https://github.com/dotnet/java-interop/blob/main/src/Java.Interop/Java.Interop/JavaObject.cs
public class JavaObject
{
    // New API to allow creation of JavaObject via the
    // IJavaCreateInstance capability defined below.
    internal void Initialize(ref JniObjectReference reference, JniObjectReferenceOptions options)
    { }
}

// Type used to express specific type universe.
public sealed class JavaTypeUniverse : ITypeMapUniverse { }

// Java capability to enable type instance creation.
public interface IJavaCreateInstance
{
    public static abstract object Create(ref JniObjectReference reference, JniObjectReferenceOptions options);
}

// Java capability implementation for object creation.
public class JavaObjectTypeMapAttribute<T> : Attribute, IJavaCreateInstance
    where T : JavaObject, new()
{
    public static object Create(ref JniObjectReference reference, JniObjectReferenceOptions options)
    {
        T instance = new T();
        instance.Initialize(ref reference, options);
        return instance;
    }
}

// Java capability implementation for string creation.
public class JavaStringTypeAttribute : Attribute, IJavaCreateInstance
{
    public static object Create(ref JniObjectReference reference, JniObjectReferenceOptions options)
    {
        IntPtr ptr = /* Extract the underlying string pointer from reference. */
        string str = Marshal.PtrToStringUni(ptr);
        return str;
    }
}

.NET for Android projection library

// Dependent association between typeof(string) => typeof(StringProxy).
[assembly: TypeMapAssociation<JavaTypeUniverse>(typeof(string), typeof(StringProxy))]

// Map string name of Java type to typeof(StringProxy), using typeof(string) to indicate
// if the entry should be included or not.
[assembly: TypeMap<JavaTypeUniverse>("java/lang/String", typeof(StringProxy), typeof(string))]

[JavaStringType]
public class StringProxy
{ }

User application

// Application reference for assembly to be used in type map generation.
[assembly: TypeMapAssemblyTarget<JavaTypeUniverse>("Android.Runtime.dll")]
[assembly: TypeMap<JavaTypeUniverse>("app/MyJavaClass", typeof(MyJavaClass), typeof(MyJavaClass))]

// User defined type
[JavaObjectTypeMap<MyJavaClass>]
public class MyJavaClass : JavaObject
{ }

Interop runtime (for example, .NET for Android) usage example.

// Stored by the interop runtime in some global static
static IReadOnlyDictionary<string, Type> s_JavaTypeMap;
TypeMapping.TryGetExternalTypeMapping<JavaTypeUniverse>(out s_JavaTypeMap);

...

string javaTypeName = /* Get Java type name from jniHandle */
s_JavaTypeMap.TryGetValue(javaTypeName, out Type? projection);
Debug.Assert(projection != null); // Assuming map contains type.

var creator = (IJavaCreateInstance)projection.GetCustomAttribute(typeof(JavaObjectTypeMapAttribute<>));
var objectRef = new JniObjectReference(jniHandle, JniObjectReferenceType.Local);
JavaObject mcw = (JavaObject)creator.Create(ref objectRef, JniObjectReferenceOptions.None);
Previous proposal
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum | AttributeTargets.Delegate | AttributeTargets.Interface, Inherited = false)]
public sealed class TypeMappingAttribute : Attribute
{
    public TypeMappingAttribute(string mapping);
}

The above attribute would be searched in all assemblies passed to a tool/MSBuild Task/etc and the result would be a binary blob. This binary blob could be in one of several forms (a) a .cs file that defines a static ReadOnlySpan<byte>, (b) a binary file that could be embedded in a .NET assembly as a resource, or perhaps another option.

Question Are there security concerns with binary blobs being written to disk?

Question: The trimmer must run prior to binary blob generation since it wouldn't be trimmable. How does this impact the workflow?

Type mapping scenarios and trimmer:

Dynamic: No special tooling has run over the whole app. The type mappings are per-assembly and registered at runtime. Tooling can be used to generate or optimize the per-assembly mapping. This should handle plugins where a new assembly with additional mappings shows up in flight.

IL trimming: The IL trimmer should not treat the types involved in the mapping as roots - if the type is not otherwise referenced by the app, it should be removed from the mapping. IL trimmer (or some other tool) generates the per-app mapping blob that is consumed at runtime. It may be easiest to make this a IL trimmer feature. This scenario does not handle plugins.

AOT: It is similar to IL trimming case, but the exact format of the blob may need to be different - both to make it more efficient and to avoid dependency on metadata tokens that expect the IL trimming implementation is going to have.

This API would be based on the generated static data of type mappings being generated using a static hashing approach for the data. From the .NET side, it could be implemented through FrozenDictionary<TKey, TValue> and an instantiated from the generated data. A static ReadOnlySpan<byte> field inserted into the application or Assembly would integration seamless, but an embedded resource is also workable. This concept is similar to existing technologies in C, such as gperf or C Minimal Perfect Hashing Library. Size and performance should be measured using different approaches.

The API shape would be a mapping from a TKey, likely a string, to an internal type, conceptually containing the following:

public struct TypeMapValue
{
    public TBD LookUpData;
    public int Index;
}

The above type contains a field for the discovery of the type, not presently loaded, and the second represents an index into an array that contains the loaded type to use. The strawman example below helps illustrate the workflow.

TypeMapping<RuntimeTypeHandle> s_XtoNETMap = new (
    s_generated_XToNET,
    (TBD data) =>
    {
        // User loads System.Type based on data.
        return type.TypeHandle;
    });

TypeMapping<IntPtr> s_NETToXMap = new (
    s_generated_NETToX,
    (TBD data) =>
    {
        // User loads type in X based on data.
    });

public sealed unsafe class TypeMapping<TType>
    where TType : unmanaged
{
    private readonly FrozenDictionary<Key, TypeMapValue> _lookUp;
    private readonly TType[] _loadedType;
    private readonly delegate* <ReadOnlySpan<byte>, Key> _hashFunction;
    private readonly Func<TBD, TType> _typeLoad;

    public TypeMapping(ReadOnlySpan<byte> generatedTypeMap, Func<TBD, TType> typeLoad)
    {
        _lookUp = ParseData(generatedTypeMap, out HashFunction hash, out int count);
        _loadedType = new TType[count];
        _hashFunction = GetHashFunction(hash);
        _typeLoad = typeLoad;
    }

    public TType this[ReadOnlySpan<byte> key] => LookUp(key);

    private TType LookUp(ReadOnlySpan<byte> key)
    {
        // Verify the type is actually "known".
        if (!_lookUp.TryGetValue(_hashFunction(key), out TypeMapValue v))
            throw new Exception("Invalid type look-up");

        // Check if the type is loaded.
        ref TType tgt = ref _loadedType[v.Index];

        // Defer to the user if the type isn't loaded.
        if (default(TType).Equals(tgt))
            tgt = _typeLoad(v.LookUpData);

        return tgt;
    }
}
@dotnet-policy-service dotnet-policy-service bot added the untriaged New issue has not been triaged by the area owner label Dec 13, 2024
@AaronRobinsonMSFT
Copy link
Member Author

AaronRobinsonMSFT commented Dec 13, 2024

@AaronRobinsonMSFT AaronRobinsonMSFT added this to the 10.0.0 milestone Dec 13, 2024
@stephentoub
Copy link
Member

it could be implemented through FrozenDictionary<TKey, TValue> and an instantiated from the generated data

Is the intent that the whole implementation would be source generated, using the FD just for its API surface area? This was in the road map for FD, but we held off on making the necessary APIs public/protected (right now implementations need to be in the same assembly). If this is a requirement, we'll need to do the due diligence as part of this to confirm the abstract APIs that need to be exposed to enable this.

FD also currently lives in the System.Collections.Immutable library, which ships in both the shared framework and as a separate nuget package. Is that an appropriate location?

@jkotas
Copy link
Member

jkotas commented Dec 13, 2024

The trimmer must run prior to binary blob generation since it wouldn't be trimmable. How does this impact the workflow?

There are a few different scenario:

  • Dynamic: No special tooling has run over the whole app. The type mappings are per-assembly and registered at runtime. Source generator can be used to generate or optimize the per-assembly mapping. This should handle plugins where a new assembly with additional mappings shows up in flight.

  • IL trimming: The IL trimmer should not treat the types involved in the mapping as roots - if the type is not otherwise referenced by the app, it should be removed from the mapping. IL trimmer (or some other tool) generates the per-app mapping blob that is consumed at runtime. It may be easiest to make this a IL linker feature. This scenario does not handle plugins.

  • Native AOT: It is similar to IL trimming case, but the exact format of the blob may need to be different - both to make it more efficient and to avoid dependency on metadata tokens that I expect the IL trimming implementation is going to have.

Perfect Hash Function

Nit: Perfect hash functions tend to waste space. I am not sure whether we want to go there. Regular hash vs. perfect hash is small implementation detail that is not important for the overall design.

@rolfbjarne
Copy link
Member

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum | AttributeTargets.Delegate, Inherited = false)]

We map Objective-C protocols to interfaces, so I think AttributeTargets.Interface should be added here.

Priorties

  1. Trimmer friendly - AOT compatible.
  2. Usable from both managed and unmanaged environments.
  3. Low impact to application start-up and/or Assembly load.
  4. Be composable - handle multiple type mappings.

One thing that drove the current design for iOS was to use as little allocated (dirty / writable) memory as possible, because in some cases (in particular app extensions on iOS), the platform poses rather strict memory limits and will terminate the process if those limits are broken.

This is why in some cases we've used statically allocated lists for our data structures (in C - which means they don't require any allocations at startup, it all lives in read-only memory the OS can page out whenever needed and it doesn't count towards the memory limits) + binary search over those lists (we can sort them at build time). The number of entries in the arrays (low thousands at the very upper end) didn't make the binary search significantly slower than a dictionary lookup.

I'm not advocating for this particular solution, but if we could find an implementation where the data can be mmap'ed into the process as read-only memory (and not copied around afterwards), I think that would be great (and also performant on all other platforms as well).

@jkotas
Copy link
Member

jkotas commented Dec 13, 2024

TBD

To figure out these details, it may be useful to work on CsWinRT and/or Objective-C interop patch that shows how this API would be consumed.

@rolfbjarne
Copy link
Member

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum | AttributeTargets.Delegate, Inherited = false)]
public sealed class TypeMappingAttribute : Attribute
{
    public TypeMappingAttribute(string mapping);
}

Actually a few more thoughts:

  1. I might want to allow adding this attribute on existing types I don't control. For instance, I might want to map System.DateTime to NSDate.
  2. I might want to map a managed type to more than one native type (once for Objective-C, once for Swift for instance).
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Enum | AttributeTargets.Delegate | AttributeTargets.Interface, Inherited = false)]
public sealed class TypeMappingAttribute : Attribute
{
    public TypeMappingAttribute(string mapping);
	public TypeMappingAttribute(string mapping, string nativeContext);
}

[TypeMapping ("OSObjCType", "Objective-C")]
[TypeMapping ("OSSwiftType", "Swift")]
public class ImportantType {}
[AttributeUsage(AttributeTargets.Assembly, Inherited = false)]
public sealed class ExternalTypeMappingAttribute : Attribute
{
    public ExternalTypeMappingAttribute(Type type, string mapping);
    public ExternalTypeMappingAttribute(Type type, string mapping, string nativeContext);
}

[assembly: ExternalTypeMapping (typeof (System.DateTime), "NSDate", "Objective-C")]
[assembly: ExternalTypeMapping (typeof (System.DateTime), "Date", "Swift")]

@Sergio0694
Copy link
Contributor

"To figure out these details, it may be useful to work on CsWinRT and/or Objective-C interop patch that shows how this API would be consumed."

Would love to work together on this! (also cc. @manodasanW, of course). I'm reading through the proposal and it's not immediately obvious to me how exactly we'd get CsWinRT to switch to this new API surface and what would the effect be in practice. Perhaps we should schedule a call once everyone is back from vacation? 🙂

@AaronRobinsonMSFT AaronRobinsonMSFT removed the untriaged New issue has not been triaged by the area owner label Dec 13, 2024
@Sergio0694
Copy link
Contributor

One of our main concerns for Native AOT WinRT components in Windows is the binary size impact of using any WinRT interop at all, because the second we bring CsWinRT in, we end up rooting pretty much the entire reflection stack due to it using GetCustomAttribute<T> to lookup the generated vtable types on projected types (and I remember @MichalStrehovsky saying that as soon as you use that API, you root everything). We want to use WinRT because the ABI is way nicer for consumption for both C# and C++ (and Rust), but the binary size delta for a minimal component doing the same exact thing, between exposing a flat C API and a WinRT one, is quite significant 🥲

Is it in scope for this proposal to make it possible for CsWinRT to potentially switch to this and stop having to lookup attributes? Would there be performance concerns in case you have lots of types (eg. the base Windows SDK projections has hundreds and hundreds of projected types, if not thousands) to using this new API to retrieve vtable info for a type, vs. just using GetCustomAttribute on the type itself directly?

@jkotas
Copy link
Member

jkotas commented Dec 14, 2024

I might want to allow adding this attribute on existing types I don't control.
I might want to map a managed type to more than one native type

Given that we need to reference external types, it may be simpler to only support the detached type mapping (ExternalTypeMapping in your example). The type mappings are expected to be generated, so they do not need to be convenient to write.

Attribute is just one of the possible ways to encode the information. A few alternative ways to encode the information:

  • Long constant string: new TypeMapping("... long string with all type mappings ..."). The trimmer and native AOT compilers would recognize the TypeMapping constructor as an intrinsic and convert the long string into a pre-initialized hashtable.

  • Method with specific pattern: TypeMapping CreateTypeMapping() { var m = new TypeMapping(); m.Add(typeof(...), ...); m.Add(typeof(...), ....); return t; }. Again, the trimmer and native AOT compilers would recognize this an intrinsic. (The special method can only contain calls to the specific and it cannot have any control flow.)

Is it in scope for this proposal to make it possible for CsWinRT to potentially switch to this and stop having to lookup attributes?

I think so - in trimmed or NAOT compiled scenarios at least.

@Sergio0694
Copy link
Contributor

Sergio0694 commented Dec 14, 2024

"I think so - in trimmed or NAOT compiled scenarios at least."

If that's the case then it might be worth also mentioning our other (primary) approach to generating vtable for types today in CsWinRT, both for projections and for user types. The two Aaron linked are just two approaches we use for either built-in custom type mappings (eg. say System.Numerics.Vector2 maps to Windows.Foundation.Numerics.Vector2), and for external types you might try to marshal (and for generic instantiations, we have that scenario too because of WinRT).

But for everything else, we use [WinRTExposedType]. This is added to all projections and user types (that's why they have to be partial), and have a type details pointing to some CsWinRT-generated type implementing IWinRTExposedTypeDetails. Then when you try to marshal one of these types, CsWinRT basically does obj.GetType().GetCustomAttribute<WinRTExposedType>() and then gets that exposed type, activates it, and invokes GetExposedInterfaces() to get the vtable entries to pass to ComWrappers.

We basically do something like this, when marshalling:

  • Does the type have the attribute? If so, use that
  • Is the type in the authored dictionary?
    • If so, get the ABI type from there and then get the attribute from it to get the vtable
  • Is the type in the mapping of built-in custom type mappings? If so, get the entries from there
  • Is the type in one of the generated global lookup tables for vtables? If so, use that
  • Otherwise:
    • If on NAOT, throw
    • If not, do some unsafe reflection nonsense

All the logic I outlined here is more or less all in this method.

It would be nice if we could somehow use this new system to streamline all these possible approaches into a single, better one 🙂

@Sergio0694
Copy link
Contributor

The more I think about this, the more I wonder: does this not have considerable overlap with the "extension interfaces" feature that @agocke was proposing? That was I was originally planning to use for CsWinRT 3.0, if it ever came out. Consider this:

// In WinRT.Runtime
public interface IWinRTExposedType
{
    static abstract ComWrappers.ComInterfaceEntry[] GetVtableEntries();
}

// Each projected type or built-in custom mapped type would also get this
public extension StringWinRTExposedTypeExtension for string : IWinRTExposedType
{
    public static ComWrappers.ComInterfaceEntry[] GetVtableEntries() => ...;
}

// For user types, the generator would simply generate these extensions instead

Then in CsWinRT, we can now simply do T.GetVtableEntries() in all our interop logic, and everything would just work perfectly, and it'd be fully trimmable as well. This would also have the advantage that it'd be statically verifiable, as in, we could constrain type parameters in APIs to types implementing the interface, rather than just doing a lookup at runtime and throwing if missing.

I suppose what I'm wondering is: are these two features just different ways of achieving the same, or would we want something like this even if we had extension interfaces already? If not, would it make sense to try to push for extension interfaces instead, as they would provide a more generalized solution that would also be useful in scenarios other than interop? 🤔

@jkotas
Copy link
Member

jkotas commented Dec 14, 2024

I do not think there was ever "extension interface" proposal that had a chance of working. Extension interfaces discussions that I have seen were about hard problems that nobody has a solution for.

@Sergio0694
Copy link
Contributor

Sergio0694 commented Dec 14, 2024

I'm referring specifically to the one Andy had (I think there's a detailed proposal somewhere in csharplang, can't find it right now though), which was a simplified version of the generalized extension interfaces feature that was first proposed, and used the "orphan rule" to handle loading the extensions. Which should work just fine here in theory, because generally speaking there's only two cases:

  • You need to map built-in types: you can include those extensions in the same assembly that defined the interface (eg. CsWinRT would do this in its assembly for all built-in and custom mapped types)
  • You need to map user types: in that case the extension is generated in the same assembly where that type is defined.

Just thinking out loud and wondering whether there could indeed be some overlap here, and whether one approach might be preferable over the other. I understand that extension interfaces would likely be more costly though.

@MichalStrehovsky
Copy link
Member

I'll transfer a couple of notes from #110300 (comment) since they seem relevant:

  • Encoding this information in custom attributes means that either the custom attributes will still be there after trimming (not the end of the world, but not great), or we remove them. Removing them would be an observable behavior difference (all custom attribute APIs are marked trim safe and therefore should produce the same results after trimming). One way out of this could be a custom attribute with a constructor that is marked with ConditionalAttribute - I think those would still be visible to source generators, but they wouldn't be preserved into IL unless the conditional symbol is defined (and we wouldn't define it). But then the source generator still needs to rewrite this into something that is not a custom attribute.
  • Encoding this in method parameters makes it more difficult to remove. E.g. TypeMapping CreateTypeMapping() { var m = new TypeMapping(); m.Add(typeof(...), ...); m.Add(typeof(...), ....); return t; } discussed above - Roslyn could decide to introduce temporaries for the typeof, even could go as far as sharing typeof expression between calls using a temporary. If we find out we need to remove an Add call, it might be complicated.
  • In that comment I liked generic method calls the most because it's hard for Roslyn to be smart about them and it will always be just a call that is easy to analyze and easy to remove.

I'm also interested in the lookup logic: Func<TBD, TType> typeLoad makes it look like we expect user code to do the actual lookup. Is there an example of how user code would do it? Would they use something like Type.GetType? How do we make sure the target exists? In my mind, I expected the lookup to be encapsulated within the API and user would be presented with a RuntimeType instead of having to write some RuntimeType lookup by themselves (alternatively, instead of giving the user a RuntimeType, we could call some generic method with the T substituted with the result of the lookup - this could allow calling static interface methods on the result of the lookup, for example.

Another question is about preservation - I understand this would keep the mapped type, but how about members on the type - would we preserve any? Would it be configurable?

@AaronRobinsonMSFT
Copy link
Member Author

Is the intent that the whole implementation would be source generated, using the FD just for its API surface area?

@stephentoub I don't think FD is the correct public API, but it is correct as an implementation detail.

FD also currently lives in the System.Collections.Immutable library, which ships in both the shared framework and as a separate nuget package. Is that an appropriate location?

@stephentoub Yes, I believe that should be fine. I don't think we would need anything in SPCL to be aware of this.

There are a few different scenario

@jkotas Nice list. I will integrate that into the issue.

Nit: Perfect hash functions tend to waste space. I am not sure whether we want to go there. Regular hash vs. perfect hash is small implementation detail that is not important for the overall design.

@jkotas Yep, perfect hash functions have that behavior. There are also minimum perfect hash functions, based on all the keys which we can explore. I agree this is an implementation detail, but size and speed are both dimensions that will need to be explored.

We map Objective-C protocols to interfaces, so I think AttributeTargets.Interface should be added here.

@rolfbjarne Good point.

This is why in some cases we've used statically allocated lists for our data structures (in C - which means they don't require any allocations at startup

@rolfbjarne Sure. The data containing the dictionary/hashmap will be just that - static binary data blob. The secondary look-up array was choosen as a compromise between a large assembly image vs some of CoreCLR's other allocation mechanisms. Definitely an implementation angle to experiement with.

I'll transfer a couple of notes from #110300 (comment) since they seem relevant:

@MichalStrehovsky Thanks. These are helpful.

I'm also interested in the lookup logic: Func<TBD, TType> typeLoad makes it look like we expect user code to do the actual lookup. Is there an example of how user code would do it?

@MichalStrehovsky That, to me, seems like an implementation detail for each specific interop case. Where if the assembly is on disk or embedded somewhere, it needs to be defined for the specific scenario. Also, it needs to have some flexibility for the non-.NET loading case. My expectation here is this would be a low level API for building up whatever interop is needed, so finding and loading a Java class library is going to be very different from finding and loading a Swift library. It is quite likely for the .NET case we would have a few built-in that were created during source generation.

Another question is about preservation - I understand this would keep the mapped type, but how about members on the type - would we preserve any? Would it be configurable?

@MichalStrehovsky My experience here is that we shouldn't be trimming across the interop boundary lower than a type. For years the trimmer has broken COM scenarios and always get's it wrong in some terrible way. When a type is involved in interop, it is either trimmed entirely or left as-is. That is my preference for now. Happy to learn better approaches here, but I'm suspicious of it getting it right.

@jkotas
Copy link
Member

jkotas commented Dec 16, 2024

For years the trimmer has broken COM scenarios and always get's it wrong in some terrible way. When a type is involved in interop, it is either trimmed entirely or left as-is.

It was the case for built-in COM interop that had fundamental problems with trimming. The newer trim-compatible interop systems do not have this problem, the trimmer does not special cases them and trims types involved in interop at method granularity like any other type.,

@AaronRobinsonMSFT
Copy link
Member Author

The newer trim-compatible interop systems do not have this problem, the trimmer does not special cases them and trims types involved in interop at method granularity like any other type.,

How does it do this for COM? As recently as last year, the trimmer broke ComWrappers by removing a method and breaking the vtable layout.

@jkotas
Copy link
Member

jkotas commented Dec 16, 2024

As recently as last year, the trimmer broke ComWrappers by removing a method and breaking the vtable layout.

Could you please share a link to the issue?

@AaronRobinsonMSFT
Copy link
Member Author

As recently as last year, the trimmer broke ComWrappers by removing a method and breaking the vtable layout.

Could you please share a link to the issue?

There wasn't an issue. It was during an inner dev cycle and I was reminded it had to do with the library trimming scenario, not the official trimmer scenario.

@am11
Copy link
Member

am11 commented Dec 17, 2024

perfect hash functions have that behavior. There are also minimum perfect hash functions, based on all the keys which we can explore. I agree this is an implementation detail, but size and speed are both dimensions that will need to be explored.

Even with MPHFs, an additional lookup table for the actual data is needed, in addition to the hash table itself. Consequently, the total object size is always larger than the original data. Note that the size difference between PHFs and MPHFs relates to the hash table size, not the entire key-value table. MPHFs aim to keep the hash table size close to the original keys while eliminating collisions, but it's only part, not the whole structure.

An ideal hash would pack the key and value into a single number and operate on the key bits portion during the lookup, a fascinating concept, though such a function hasn't been discovered yet. 😅

If speed is the bottleneck and the size tradeoff due to the extra lookup table is acceptable, opting for a perfect or minimal perfect hash implementation is reasonable. Otherwise, classic solutions like bsearch in C or [Frozen]Dictionary in C# are perfectly fine for achieving a good balance between performance and simplicity.

@jkoritzinsky
Copy link
Member

Looking over the proposal as-is, there's a few questions that come to my mind:

  • Is this for mapping "unmanaged type name in ecosystem X" to "managed type name Y"? Or is this supposed to provide a mechanism to safely represent all the information that may be necessary to map a type (ie. ABI type, vtables, etc)? The current shape only represents the type name mapping. Providing an API shape that would allow a projection to say "here's a type/types that owns additional marshalling info" would be useful to solve @Sergio0694's questions around CsWinRT and it should still be possible to persist into a static readonly data structure at app-build time.

  • Having some mechanism to say "managed type Y maps to type X in Objective-C and type Z in Swift" is a useful concept, particularly for primitive types (as every interop marshalling system will map at least some primitive types in some way).

@stephen-hawley
Copy link

For binding to Swift, this is used strictly for bind time use. As far as I'm concerned the information can get stripped out at build time. What is needed is:

  • The Swift module name and the type name
  • The C# namespace and the type name
  • The Swift entity type (struct, class, enum, actor)
  • The size, stride, and alignment if it's a value type*
  • Whether or not the type is frozen
  • Whether or not the type is blitable

There are at least 4 main tasks that will use this information:

  • defining the shape/blitability of new types
  • mapping argument/types and return values from the Swift name to the C# name
  • determining C# using dependencies
  • determining calling conventions and call targets (for non-frozen value types they will need to be pass by reference and virtual methods we will need to go through thunks)

@AaronRobinsonMSFT
Copy link
Member Author

/cc @vitek-karas @ivanpovazan

@Sergio0694
Copy link
Contributor

I think we (CsWinRT) basically need #50333, on top of the interop table map, to properly get the semantics we're looking for.
cc. @MichalStrehovsky @vitek-karas we should chat some time next week to see if we could make this happen 😄

@AaronRobinsonMSFT
Copy link
Member Author

@agocke, I've updated the proposal based on offline conversations.

All, I've placed the previous proposal under a drop down in the description - nothing should be lost.

@Sergio0694
Copy link
Contributor

Sergio0694 commented Feb 22, 2025

Might also want to list #50333 in the related issues in the first post? This proposal effectively supersedes that 🙂
And also #112287.

@Sergio0694
Copy link
Contributor

@AaronRobinsonMSFT a couple more questions:

"This mapping will only exist in the type map if the Trimmer observes an allocation using the System.Type represented by source."

Should we just say "if the Trimmer marks the System.Type represented by source as constructed"? The term "allocation" here seems a bit confusing, in that the semantics here should be that mappings will also exist for boxed value types 🤔

"This method will throw System.NotImplementedException if the type map was not generated."

This applies to both TypeMap APIs. How come the return value is nullable, if the method will just throw if the type map is missing?

@AaronRobinsonMSFT
Copy link
Member Author

AaronRobinsonMSFT commented Feb 22, 2025

The term "allocation" here seems a bit confusing, in that the semantics here should be that mappings will also exist for boxed value types

A boxed type would mean an allocation, no?

This applies to both TypeMap APIs. How come the return value is nullable, if the method will just throw if the type map is missing?

Not sure if we want to allocate a dictionary if no map was created. Doesn't seem to make a lot of sense to me, but I'll leave that up to API review or the Trimmer team (cc @agocke / @sbomer) to say if they prefer an empty dictionary or null.

@Sergio0694
Copy link
Contributor

"A boxed type would mean an allocation, no?"

Yeah no I get that's what it meant, just meant people usually say "allocation of a type" to refer to objects of that type (as in, a reference type) being instantiated, not boxed value types. But alright, fair enough 😅

"Not sure if we want to allocate a dictionary if no map was created."

I get that, but I'm saying the docs state the method will just throw if the map is missing. Meaning the return type doesn't matter. The only case where the method won't throw is if there is a map, meaning the return type is not nullable. Is that not the case?

@jkotas
Copy link
Member

jkotas commented Feb 23, 2025

prefer an empty dictionary or null.

The behavior for missing typemap created at build time can be one of the following:

  1. Throw NotImplementedException.
  2. Return null. In this case, the API may want to follow bool TryGet... pattern.
  3. Build the typemap at runtime by walking metadata. Runtime typemap building may want to be a separate API to give callers control over runtime type map building.

I think we should do (2) as part of this proposal (change the API shape to the TryGet... pattern), and consider doing (3) in future if we find good motivating scenario for it.

@AaronRobinsonMSFT
Copy link
Member Author

I think we should do (2) as part of this proposal (change the API shape to the TryGet... pattern), and consider doing (3) in future if we find good motivating scenario for it.

Agree. Will update API.

@AaronRobinsonMSFT
Copy link
Member Author

Not to get too much into the weeds on this since we can let the API review process work, but the bool TryGet... pattern might not be entirely natural here. I think I would prefer IReadOnlyDictionary<,>? TryGet... as it is easier to naturally replace as a static being initialized and in code in general. I think the bool is noise and we shouldn't get too bogged down on the classic pattern. I say that because it is obvious that null can be returned and that is "fail" and this will be used by a startling few number of people so following the precise pattern should be a distant concern in my opinion.

@jkotas
Copy link
Member

jkotas commented Feb 24, 2025

it is easier to naturally replace as a static being initialized and in code in general.

The shape of the Try pattern has minimal impact on the implementation. It is like 5 line code delta between the different shapes of the Try pattern.

it is obvious that null can be returned and that is "fail" and this will be used by a startling few number of people

You can make the same argument for many advanced Try pattern APIs. We use the standard Try pattern for all APIs where it makes sense irrespective of how advanced they are. I do not recall a case where API review approved non-standard Try pattern.

@Sergio0694
Copy link
Contributor

What's the advice for cases where one needs to have a proxy for an internal type in case you cannot possibly generate attributes in its containing assembly? To make an example: we expect to be able to pretty much always do this, but I am not sure I see a way to do that for types internal to the BCL. In CsWinRT we need special mapping for some of them, such as:

  • System.Collections.Specialized.ReadOnlyList
  • System.Collections.Specialized.SingleItemReadOnlyList

Would it be possible to add a constructor taking a string with a constant type name, like we mentioned? Eg.:

[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
public sealed class TypeMapAssociationAttribute<TTypeUniverse> : Attribute
    where TTypeUniverse : ITypeMapUniverse
{
    public TypeMapAssociationAttribute(string sourceTypeName, string sourceAssemblyName, Type proxy)
    { }
}

Then we could use it like so:

// In WinRT.Runtime.dll
[assembly: TypeMapAssociation<WindowsRuntimeTypeUniverse>(
    "System.Collections.Specialized.ReadOnlyList",
    "System.Private.CoreLib, Version=8.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e",
    typeof(ReadOnlyListProxyType))]

@AaronRobinsonMSFT
Copy link
Member Author

AaronRobinsonMSFT commented Feb 24, 2025

types internal to the BCL

There is nothing stopping us from changing these types. How is this a valid or safe marshalling scenario?

My initial reaction would be, you can't do that and if you are it should be stopped. Can you describe the justification for this scenario?

@Sergio0694
Copy link
Contributor

Sergio0694 commented Feb 24, 2025

Don't get me wrong, I hate that we need this, and I'd love to have a better solution. The problem is that we need to know all the relevant info on a given type when we try to marshal. This is generally done by only allowing marshalling for types we can "see", so that we can generate the full vtable at compile time. We can't dynamically construct it at runtime, because information that is relevant (such as the implemented interfaces) might be trimmed. This is generally fine, as virtually all types are visible.

The problem is that ObservableCollection<T>, which is an extremely common type in GUI scenarios, uses internal types as the list of items being changed, from CollectionChangedEventArgs.NewItems and OldItems (specifcally, those two types I mentioned). So we need some way to still correctly marshal them. We're doing that currently by just taking a dependency on that implementation detail, and pregenerating the right vtables for those two full type names for the internal types.

Other possible ways to fix it would be:

  • Make those two collection types public somewhere
  • Have ObservableCollection<T> start using a public collection type for NewItems and OldItems

I assume option (2) hasn't been done because that SingleItemReadOnlyList provides a nice perf/memory improvement, as I'd imagine the vast majority of collection changed events in real world scenarios are with just one item at a time so far.

If there's some other better solution you have in mind, I'd love to hear it 😅

EDIT: here is the exact code in CsWinRT where we currently handle this.

@AaronRobinsonMSFT
Copy link
Member Author

AaronRobinsonMSFT commented Feb 24, 2025

uses internal types as the list of items being changed, from CollectionChangedEventArgs.NewItems and OldItems (specifcally, those two types I mentioned). So we need some way to still correctly marshal them.

I'm not following this argument. The APIs declare they use the non-generic IList. Why can't that be the type being marshalled? Surely CsWinRT doesn't need to know the concrete type whenever an interface is used in a signature. What makes this collection unique? The SingleItemReadOnlyList and ReadOnlyList seems like internal performance optimizations and I don't get why this matters for CsWinRT.

@MichalStrehovsky
Copy link
Member

If the trim-target type is used in a type check, then the entry will be inserted into the map

For posterity, this will end up with more types than those used in type checks because in the limit this is possible:

// No trimming or AOT warning here, this is safe:
// T could be any object reachable through reflection or allocated in the app.
typeof(Gen<>).MakeGenericType(someObject.GetType()).GetMethod("Check").Invoke(null, []);

class Gen<T> where T : class
{
    public static bool Check(object o) => o is T;
}

I don't think it would cause problems, but wanted to spell it out in case there is a problem that I don't see.

If Trimmer observes an explicit allocation of the source type, the entry will be inserted into the map.

Does this mean we'd need to prevent any optimizations around allocation from happening? E.g. if we end up with a side-effect free equivalent of _ = new Foo(), we'd today optimize Foo away. Would we be forbidden to do that here, or can we add some nuance here (type was allocated but wasn't optimized away; we're still free to optimize around this).

For this part:

[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public sealed class TypeMapAttribute<TTypeUniverse> : Attribute
{
    [RequiresUnreferencedCode("Interop types may be removed by trimming")]
    public TypeMapAttribute(string value, Type target, Type trimTarget) { }
}

I'm actually not sure if we'll be able to suppress this RUC warning here. @sbomer can you think of a way to suppress the warnings when this attribute is applied on an assembly? Given that the methods on TypeMapping are marked as RequiresUnreferencedCode, my understand is that we'd only need to mark the attribute itself like this because we're going to drop these attributes when trimming. I wonder if we could do something different, like make this attribute invisible to user reflection in general (even when JITted). We don't like trimming attributes because our attribute APIs are not annotated as RequiresUnreferencedCode and they're expected to behave the same in trimmed apps.

The Trimmer will consider calls to TypeMapping.GetExternalTypeMapping<> and TypeMapping.GetTypeProxyMapping<> as intrinsic operations and replaced inline with the appropriate
map instantiation

I assume this would be an error - will we be able to make it an error in a source generator/analyzer so that it errors out in the JITted case too?

static Type Wrap<T>(Type t) where T : ITypeMapUniverse => TypeMapping.TryGetExternalTypeMapping<T>(out var v) ? v.GetValueOrDefault(t) : null;

@AaronRobinsonMSFT
Copy link
Member Author

I don't think it would cause problems, but wanted to spell it out in case there is a problem that I don't see.

Agree. @agocke and @sbomer really helped with the Trimmer semantics here so I'll defer to them to comment.

Given that the methods on TypeMapping are marked as RequiresUnreferencedCode, my understand is that we'd only need to mark the attribute itself like this because we're going to drop these attributes when trimming.

This was as suggestion because there are two constructors and one is problematic. Perhaps I misunderstood the suggestion. /cc @sbomer

I assume this would be an error - will we be able to make it an error in a source generator/analyzer so that it errors out in the JITted case too?

The only type this would return true would be if trimming was enabled. We weren't planning on using a source generator for this API. It would be considered an intrinsic by the Trimmer and replaced inline. I don't think we would have the JIT fail because we want the question to be asked - "was a type map generated?"

@sbomer
Copy link
Member

sbomer commented Feb 24, 2025

I'm actually not sure if we'll be able to suppress this RUC warning here. @sbomer can you think of a way to suppress the warnings when this attribute is applied on an assembly? Given that the methods on TypeMapping are marked as RequiresUnreferencedCode, my understand is that we'd only need to mark the attribute itself like this because we're going to drop these attributes when trimming.

Here's my thinking:

The TryGet* APIs aren't trim-compatible because it relies on the requested types being kept when trimming. One way to make this technically trim-compatible would be to require explicit registration of all the required types by the app in a way that isn't impacted by trimming (or the type check heuristic).

We're not doing that because it doesn't satisfy the back-compat constraints, but I am still thinking of what the API could look like if we ever added a "strict mode", maybe for other app models. In that mode the TypeMap* attributes would contribute to the map only for registered types, and the trimTarget parameter would be ignored/unnecessary - so I wanted a way to separate out the unnecessary parameter from the "clean" API, hence the suggestion to make it RUC.

It's not really RUC in the usual sense, because the presence of the attribute alone is not a problem (the problem is its participation in the heuristic-based lookup - nothing to do with attribute removal). Since the linker will need to understand these attributes, I think we could add built-in logic to suppress the warning (in non-"strict mode").

@sbomer
Copy link
Member

sbomer commented Feb 24, 2025

Agree. @agocke and @sbomer really helped with the Trimmer semantics here so I'll defer to them to comment.

I think of the "keep map entries when a type is checked" as a heuristic, so I don't see a problem with it being overly conservative (as long as it doesn't keep everything). I think this is fine.

@agocke
Copy link
Member

agocke commented Feb 24, 2025

If Trimmer observes an explicit allocation of the source type, the entry will be inserted into the map.

Does this mean we'd need to prevent any optimizations around allocation from happening? E.g. if we end up with a side-effect free equivalent of _ = new Foo(), we'd today optimize Foo away. Would we be forbidden to do that here, or can we add some nuance here (type was allocated but wasn't optimized away; we're still free to optimize around this).

I think we're still OK to optimize this. As far as I can tell, this will be used via object.GetType(), which is a normal path in trimming. I would assume that we're free to optimize in a normal way.

@MichalStrehovsky
Copy link
Member

The only type this would return true would be if trimming was enabled. We weren't planning on using a source generator for this API. It would be considered an intrinsic by the Trimmer and replaced inline. I don't think we would have the JIT fail because we want the question to be asked - "was a type map generated?"

I didn't realize the Try methods on TypeMapping would return false when not trimmed/AOT compiled. My expectation was that there would be a fallback reflection (or S.Reflection.Metadata) based implementation that does the "obvious" thing (enumerate TypeMapAssemblyTarget attributes on the entrypoint assembly and go look for more attributes in those at runtime - we could potentially make a lot of this lazy with S.R.Metadata - potentially even without doing any type loading during the scan since we could make the internal implementation based on text strings and only load a Type when needed). That way the interop layer could run the same code whether the app is trimmed or not.

@Sergio0694
Copy link
Contributor

"That way the interop layer could run the same code whether the app is trimmed or not."

That certainly sounds much nicer for downstream consumers (eg. CsWinRT) 😅

@AaronRobinsonMSFT
Copy link
Member Author

My expectation was that there would be a fallback reflection

@MichalStrehovsky Yes, that is a possible future option. We don't have a compelling need for that at the moment though and building and maintaining it isn't a priority. It is something that has been discussed, but for now libraries that find it interesting/useful could easily layer their own logic in this case and if we see need we can provide it.

@Sergio0694
Copy link
Contributor

Sergio0694 commented Feb 24, 2025

What would the recommendation be for libraries to do this? For instance, consider CsWinRT. We can call the TypeMap API, and see if we have a map. If we don't, how do we distinguish between "we're on NAOT, so something is wrong, just throw" and "we're not on NAOT and we're not trimming, so use the fallback logic instead"? We can only check for RuntimeFeature.IsDynamicCodeCompiled and similar, but all of those properties will always return the same value no matter if you're actually on NAOT or not.

@AaronRobinsonMSFT
Copy link
Member Author

"we're on NAOT, so something is wrong, just throw" and "we're not on NAOT and we're not trimming, so use the fallback logic instead"?

If the API returns false, no map was generated and you should do what is best if you have no map. This API will be a Trimmer intrinsic so it will only return true if a map was generated. If you can't operate without a map, then I would expect the CsWinRT scenario to always call the trimmer and thus will have a map.

@Sergio0694
Copy link
Contributor

Sergio0694 commented Feb 24, 2025

Ok so basically we would always do the fallback (and suppress any trim/AOT warnings there), and assume that it'd only ever run when there's no trimming? It feels like calling TypeMap APIs is pretty much an IsTrimmingEnabled API with extra steps? 😅

To clarify, we can work with this. Not as nice as having a built-in fallback, but not a blocker either of course.

@MichalStrehovsky
Copy link
Member

If you can't operate without a map, then I would expect the CsWinRT scenario to always call the trimmer and thus will have a map.

Do we know if this is feasible? E.g. whether the JIT-based F5 launch can deal with ILLink running over the produced assemblies without causing issues with things like debugging and hot reload? It feels like a can of worms on the surface of it.

I've been thinking about this some more and have another observation: this proposal is currently solving two things:

  1. A general-purpose way to express type maps that is amenable to trimming
  2. A startup optimization to make type maps start up fast at run time, consume little memory, and have fast lookups

We're currently locking the startup optimization behind "you need to run ILLink" because the proposal expects the startup optimization will happen in ILLink (The above will be replaced by the Trimmer with an instantiation of the appropriate map.). If downstream interop wants to have good startup characteristics without ILLink they need to come up with some custom mechanism that is essentially a different implementation of the type map (my understanding is that the problem at hand is the same).

We actually have a different component that concerns itself with startup that is not ILLink - ReadyToRun. I'm wondering a bit whether it would be more natural to do the startup optimization there. ReadyToRun doesn't just cache code, it also collects different observations about the input IL - it builds a hashtable for fast lookup of types by name, another table for fast lookup of attributes, etc. It could build hash tables for type maps too.

It might be worth a thought to structure this so that:

  • ILLink enforces various invariants based on the defined type map and generates a subset of the type map attributes that survived trimming (a trimmed type map)
  • crossgen2 generates fast lookup tables based on attributes (before/after trimming, it doesn't matter to crossgen2)
  • There is a fallback S.R.Metadata-based implementation that just reads the attributes and builds a map at runtime if ReadyToRun doesn't provide a map

Native AOT would obviously do this differently, but that applies to this proposal too.

The obvious disadvantage is that Mono doesn't have ReadyToRun but maybe it would be fine to just run the fallback code. It could also be similarly integrated into Mono AOT if absolutely needed, but that's work we'd ideally want to avoid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: No status
Development

No branches or pull requests