Skip to content

[TrimmableTypeMap] Per-assembly typemap universes for incremental Debug builds + merged Release builds #11180

@simonrozsival

Description

@simonrozsival

Problem

The trimmable typemap currently generates one _Foo.TypeMap.dll per source assembly, but the generation is not truly incremental because of cross-assembly aliases.

The TypeMapping.GetOrCreateExternalTypeMapping<T>() API from dotnet/runtime collects [assembly: TypeMap<T>(key, type)] attributes across all assemblies into a single flat IReadOnlyDictionary<string, Type>. Duplicate keys are not allowed. When multiple assemblies bind different C# types to the same Java class (e.g., Java.Lang.Object in Mono.Android and JavaObject in Java.Interop both map to java/lang/Object), we must avoid key collisions.

Today this is handled by MergeCrossAssemblyAliases (in #11177), which:

  1. Scans all assemblies globally to find JNI name collisions
  2. Moves peers from one assembly's group into another's
  3. Generates alias holders with indexed keys (java/lang/Object[0], java/lang/Object[1])
  4. Creates cross-assembly type references between typemap DLLs

This means any assembly change can potentially trigger regeneration of other assemblies' typemap DLLs, defeating incrementality. Cross-assembly aliases are the common case (not the exception), so this affects most builds.

Proposed solution

Debug builds: Per-assembly typemap universes — each assembly gets its own isolated dictionary, fully incremental.
Release builds: Single merged typemap universe — optimal runtime performance, no iteration overhead.

Both modes are abstracted behind the same ITypeMapWithAliasing interface, so the runtime's TrimmableTypeMap consumes them uniformly.

Build time: per-assembly universes (Debug)

Each typemap DLL generates an anchor type and uses it as the TypeMap<T> group parameter:

// _Mono.Android.TypeMap.dll
internal class __TypeMapAnchor {}
[assembly: TypeMap<__TypeMapAnchor>("java/lang/Object", typeof(Java_Lang_Object_Proxy))]

// _Java.Interop.TypeMap.dll
internal class __TypeMapAnchor {}  // different assembly, different type
[assembly: TypeMap<__TypeMapAnchor>("java/lang/Object", typeof(JavaObject_Proxy))]

Same key in different dictionaries = no collision. Each assembly is self-contained. Changing one assembly never invalidates another's typemap DLL.

Build time: merged universe (Release)

For Release builds, the generator runs the full merge and emits all mappings under a single generated anchor type in the root assembly:

// All typemap DLLs share the root assembly's anchor
[assembly: TypeMap<_Microsoft_Android_TypeMaps.__TypeMapAnchor>("java/lang/Object", typeof(Java_Lang_Object_Proxy))]
[assembly: TypeMap<_Microsoft_Android_TypeMaps.__TypeMapAnchor>("java/lang/Object[0]", typeof(JavaObject_Proxy))]  // alias

Each individual typemap assembly does not know whether it's part of a single-universe or multi-universe build. It always emits its own __TypeMapAnchor type and uses it as the group parameter. In Release mode, the generator consolidates all mappings under the root assembly's anchor after merging cross-assembly aliases.

Root assembly as a startup hook

_Microsoft.Android.TypeMaps.dll acts as a startup hook. The build sets DOTNET_STARTUP_HOOKS=_Microsoft.Android.TypeMaps.

Initialization ordering: JNIEnvInit must call RunStartupHooksIfNeeded() before TrimmableTypeMap.Initialize(). Today the order is reversed — TrimmableTypeMap.Initialize() runs first (line 166), then startup hooks (line 186). We must swap them so the startup hook can register the ITypeMapWithAliasing instance before TrimmableTypeMap.Initialize() creates the singleton and calls RegisterNatives.

The root assembly's Initialize() method constructs the appropriate ITypeMapWithAliasing and registers it:

// Generated _Microsoft.Android.TypeMaps.dll
internal class __TypeMapAnchor {} // root assembly's own anchor for Release merged universe

internal static class StartupHook
{
    static void Initialize ()
    {
        // Debug: per-assembly universes — each assembly has its own anchor
        var universes = new SingleUniverseTypeMap[] {
            new (TypeMapping.GetOrCreateExternalTypeMapping<_Mono_Android_TypeMap.__TypeMapAnchor> (),
                 TypeMapping.GetOrCreateProxyTypeMapping<_Mono_Android_TypeMap.__TypeMapAnchor> ()),
            new (TypeMapping.GetOrCreateExternalTypeMapping<_Java_Interop_TypeMap.__TypeMapAnchor> (),
                 TypeMapping.GetOrCreateProxyTypeMapping<_Java_Interop_TypeMap.__TypeMapAnchor> ()),
            // ... one per assembly
        };
        TrimmableTypeMap.SetTypeMap (new AggregateTypeMap (universes));

        // Release: single merged universe — root assembly's anchor
        // TrimmableTypeMap.SetTypeMap (new SingleUniverseTypeMap (
        //     TypeMapping.GetOrCreateExternalTypeMapping<__TypeMapAnchor> (),
        //     TypeMapping.GetOrCreateProxyTypeMapping<__TypeMapAnchor> ()));
    }
}

MSBuild enables startup hooks automatically when _AndroidTypeMapImplementation=trimmable:

  • Sets StartupHookSupport=true
  • Adds _Microsoft.Android.TypeMaps to DOTNET_STARTUP_HOOKS / STARTUP_HOOKS

Alias-aware type map abstraction

Both Debug and Release builds need alias resolution (intra-assembly in Debug, cross-assembly in Release). Instead of leaking alias logic into TrimmableTypeMap, we introduce a shared abstraction:

/// <summary>
/// Resolves types mapped to a JNI name (including aliases) and proxy types
/// for managed types. Abstracts single-universe and multi-universe topologies.
/// </summary>
interface ITypeMapWithAliasing
{
    /// <summary>
    /// Returns all types mapped to a JNI name, resolving alias holders.
    /// For non-alias entries, returns a single type. For alias groups,
    /// returns all aliased types. Across universes, flattens results.
    /// </summary>
    IEnumerable<Type> GetTypes (string jniName);

    /// <summary>
    /// Tries to get the proxy type for a managed type.
    /// First-wins across universes (each managed type exists in one assembly).
    /// </summary>
    bool TryGetProxyType (Type managedType, [NotNullWhen (true)] out Type? proxyType);
}

SingleUniverseTypeMap — wraps one IReadOnlyDictionary<string, Type> (one TypeMapLazyDictionary) and handles alias resolution within that universe:

/// <summary>
/// Used in Release for the single merged universe, and in Debug for each
/// per-assembly universe. Resolves direct entries and alias holders.
/// </summary>
class SingleUniverseTypeMap : ITypeMapWithAliasing
{
    readonly IReadOnlyDictionary<string, Type> _typeMap;
    readonly IReadOnlyDictionary<Type, Type> _proxyTypeMap;

    public IEnumerable<Type> GetTypes (string jniName)
    {
        if (!_typeMap.TryGetValue (jniName, out var mappedType))
            yield break;

        // Direct proxy entry
        var proxy = mappedType.GetCustomAttribute<JavaPeerProxy> (inherit: false);
        if (proxy is not null) {
            yield return mappedType;
            yield break;
        }

        // Alias holder — resolve each alias key within this universe
        var aliases = mappedType.GetCustomAttribute<JavaPeerAliasesAttribute> (inherit: false);
        if (aliases is null)
            yield break;

        foreach (var key in aliases.Aliases) {
            if (_typeMap.TryGetValue (key, out var aliasEntryType))
                yield return aliasEntryType;
        }
    }

    public bool TryGetProxyType (Type managedType, [NotNullWhen (true)] out Type? proxyType)
    {
        if (_proxyTypeMap.TryGetValue (managedType, out proxyType))
            return true;
        proxyType = null;
        return false;
    }
}

AggregateTypeMap (Debug only) — wraps N SingleUniverseTypeMap instances and flattens results:

/// <summary>
/// Wraps N per-assembly SingleUniverseTypeMaps and flattens their results.
/// Each universe is self-contained (no cross-assembly aliases), but the same
/// JNI name can exist in multiple universes.
/// </summary>
class AggregateTypeMap : ITypeMapWithAliasing
{
    readonly SingleUniverseTypeMap[] _universes;

    public IEnumerable<Type> GetTypes (string jniName)
    {
        foreach (var universe in _universes) {
            foreach (var type in universe.GetTypes (jniName))
                yield return type;
        }
    }

    public bool TryGetProxyType (Type managedType, [NotNullWhen (true)] out Type? proxyType)
    {
        // First-wins: each managed type exists in exactly one assembly
        foreach (var universe in _universes) {
            if (universe.TryGetProxyType (managedType, out proxyType))
                return true;
        }
        proxyType = null;
        return false;
    }
}

TrimmableTypeMap consumes ITypeMapWithAliasing uniformly — no conditional logic:

readonly ITypeMapWithAliasing _typeMap;
// Release: SingleUniverseTypeMap (single merged dict)
// Debug:   AggregateTypeMap (N per-assembly dicts)

Key simplification: TrimmableTypeMap no longer needs to know about aliasing at all. Today, GetProxiesForJniName manually checks for JavaPeerAliasesAttribute, resolves alias keys, and iterates indexed entries. With ITypeMapWithAliasing, all alias resolution is encapsulated in SingleUniverseTypeMap.GetTypes(). TrimmableTypeMap.GetProxiesForJniName becomes a simple loop:

JavaPeerProxy[] GetProxiesForJniName (string jniName)
{
    return _jniProxyCache.GetOrAdd (jniName, static (name, self) => {
        var result = new List<JavaPeerProxy> ();
        foreach (var type in self._typeMap.GetTypes (name)) {
            var proxy = type.GetCustomAttribute<JavaPeerProxy> (inherit: false);
            if (proxy is not null)
                result.Add (proxy);
        }
        return result.Count > 0 ? result.ToArray () : [];
    }, this);
}

Similarly, GetProxyForManagedType simplifies to just calling _typeMap.TryGetProxyType() — no more JavaPeerAliasesAttribute fallback path, no GetProxyFromAliases helper.

The existing _jniProxyCache and _proxyCache in TrimmableTypeMap continue to cache resolved results, so ITypeMapWithAliasing methods are only called once per key.

crossgen2 / ReadyToRun opportunity

Individual per-assembly typemap DLLs are excellent candidates for ahead-of-time compilation via crossgen2. Since each universe is self-contained, it can be independently precompiled — the TypeMapLazyDictionary backing each universe becomes a fast R2R lookup even in Debug builds.

This aligns with and supersedes #10792 (precompiled typemap assemblies). The per-assembly split naturally creates small, stable compilation units that benefit from R2R. Combined with dotnet/runtime#124352 (TypeMap / assembly-level metadata R2R improvements), Debug-build typemap lookups can approach Release-build performance.

What changes

Component Debug Release
MergeCrossAssemblyAliases Not used — each assembly is self-contained Used (as today)
Per-assembly codegen Fully incremental — own anchor type, no cross-assembly refs Global merge, then split
Root assembly Startup hook → AggregateTypeMap from N universes Startup hook → SingleUniverseTypeMap from merged dict
Runtime (TrimmableTypeMap) Receives ITypeMapWithAliasing via SetTypeMap() Receives ITypeMapWithAliasing via SetTypeMap()
Cross-assembly alias holders Not needed Used for collisions
Intra-assembly aliases Unchanged (handled by ModelBuilder) Unchanged

What stays the same

  • Proxy types, UCO wrappers, RegisterNatives — already per-assembly
  • Intra-assembly alias holders — already handled by ModelBuilder
  • Trimming — each universe is independently trimmable

Files to update

  • src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs — conditional merge (Release only), per-assembly anchors (Debug)
  • src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs — emit anchor type per assembly
  • src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/RootTypeMapAssemblyGenerator.cs — emit startup hook Initialize() method that constructs AggregateTypeMap (Debug) or SingleUniverseTypeMap (Release) and calls TrimmableTypeMap.SetTypeMap()
  • src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs — add SetTypeMap(ITypeMapWithAliasing) static method; consume ITypeMapWithAliasing instead of raw IReadOnlyDictionary; alias resolution moves into SingleUniverseTypeMap
  • src/Mono.Android/Microsoft.Android.Runtime/ITypeMapWithAliasing.cs — new: interface + SingleUniverseTypeMap + AggregateTypeMap
  • src/Mono.Android/Android.Runtime/JNIEnvInit.cs — swap initialization order: RunStartupHooksIfNeeded() must run before TrimmableTypeMap.Initialize()
  • MSBuild targets — pass configuration to generator, control merge vs per-assembly mode, enable StartupHookSupport=true and set DOTNET_STARTUP_HOOKS=_Microsoft.Android.TypeMaps for trimmable typemap

Related issues

Open questions

  • Anchor type naming: Generate __TypeMapAnchor in each typemap DLL? Or use a convention based on source assembly name?
  • Incremental Release builds: Could we detect "no cross-assembly collisions changed" and skip the merge? Or is the merge cheap enough that it doesn't matter for Release?

Part of #10788

Metadata

Metadata

Assignees

No one assigned

    Labels

    copilot`copilot-cli` or other AIs were used to author thisneeds-triageIssues that need to be assigned.trimmable-type-map

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions