Skip to content

[TrimmableTypeMap] Eliminate JavaPeerContainerFactory<T> per-type bloat #11234

@simonrozsival

Description

@simonrozsival

Background

The trimmable typemap currently relies on JavaPeerContainerFactory<T> to provide AOT-safe creation of JavaList<T>, JavaCollection<T>, JavaDictionary<K, V>, and T[] instances. The factory is auto-instantiated by every non-generic peer's proxy via the JavaPeerProxy<T>.GetContainerFactory() base method:

// src/Mono.Android/Java.Interop/JavaPeerProxy.cs
public abstract class JavaPeerProxy<T> : JavaPeerProxy where T : class, IJavaPeerable
{
    public override JavaPeerContainerFactory GetContainerFactory ()
        => JavaPeerContainerFactory<T>.Instance;
}

This forces a unique JavaPeerContainerFactory<T> instantiation per peer type T, even for peer types never used as collection element types. Each instantiation also pulls in:

  • new T[length], new T[length][], new T[length][][] (ranks 1-3)
  • new JavaList<T>(...)
  • new JavaCollection<T>(...)
  • new JavaDictionary<T, *>(...)

For a typical .NET MAUI app with ~2000 binding types, this is ~2000 instantiations of the factory plus the cascading container generic instantiations — substantial NativeAOT app size impact.

Active call sites (3)

  1. JavaConvert.TryGetFactoryBasedConverter — marshaling IList<T> / ICollection<T> / IDictionary<K, V> from JNI handles
  2. JNIEnv.ArrayCreateInstance — creating T[] arrays for JNI marshaling
  3. JavaPeerProxy<T>.GetContainerFactory() — root cause of bloat (auto-implementation in base class)

Removal plan

Phase 1: Replace CreateList/CreateCollection/CreateDictionary with ActivateUsingReflection

The ActivateUsingReflection helper added in #11225 already handles closed generic activation via reflection with [DynamicallyAccessedMembers(Constructors)] flowing to the closed types. Use it for the 3 collection methods.

Caveat: MakeGenericType(typeof(JavaList<>), elementType) is not AOT-safe.
Solution: Use the trimmable typemap to look up the closed JavaList<T> type for known element types, then call ActivateUsingReflection on it.

Removes: CreateList, CreateCollection, CreateDictionary, CreateDictionaryWithValueFactory from JavaPeerContainerFactory<T>. Eliminates ~75% of the per-type bloat.

Phase 2: Array types — runtime fork between CoreCLR and NativeAOT

Tracking PR: #11238 (currently in scaffolding state pending the .NET SDK bump that brings dotnet/runtime#126380 — ships in 11.0.100-preview.5.26228.123).

Design

JNIEnv.ArrayCreateInstance branches on RuntimeFeature.IsDynamicCodeSupported:

static Array ArrayCreateInstance(Type elementType, int length)
{
    if (!RuntimeFeature.TrimmableTypeMap)
        return Array.CreateInstance(elementType, length);   // legacy fallback unchanged

    if (RuntimeFeature.IsDynamicCodeSupported) {
        // CoreCLR / Mono — runtime type loader can construct any T[] dynamically.
        // No typemap roundtrip; unlimited array rank.
        return Array.CreateInstance(elementType, length);
    }

    // NativeAOT — opaque from the trimmer's POV, so we resolve the closed array
    // type through the typemap and use AOT-safe Array.CreateInstanceFromArrayType.
    if (TrimmableTypeMap.Instance.TryGetArrayType(elementType, out var arrayType))
        return Array.CreateInstanceFromArrayType(arrayType, length);

    throw new NotSupportedException(
        $"No TrimmableTypeMap array entry for element type '{elementType}'. " +
        "Add an [assembly: TypeMap] entry or report an issue.");
}

Why a runtime fork. The two trimmers have very different array-shape semantics:

Trimmer Array-shape tracking Implication for typemap design
ILC (NativeAOT) Per-TypeDescstring[], string[][] are distinct nodes. Per-shape conditional dependencies via NecessaryTypeSymbol. Speculative emission works correctly: T[] entry is dropped iff T[] is never constructed in IL.
ILLink (CoreCLR PublishTrimmed) Per-TypeDefinition. UnwrapToResolvableType strips array wrappers down to the element TypeDef before tracking. Speculative T[] entry is alive whenever T is reachable, regardless of whether T[] is constructed. 3× per-peer attribute cost with no opportunity to cull.

CoreCLR has a runtime type loader that can construct arbitrary T[] dynamically, so the typemap roundtrip is unnecessary work. NativeAOT cannot construct arbitrary T[] for value-type elements (and the JNI-marshaling call sites are opaque to the trimmer either way), so the typemap is the only AOT-safe route.

Generator changes

Emit array-shape entries only when the build targets NativeAOT ($(PublishAot) == true). Use a dedicated TypeMap<TGroup> per rank, keyed by the element JNI name (no [L<jni>; prefix construction at runtime):

// In _<App>.TypeMap.dll, only when PublishAot=true:
internal sealed class __ArrayMapRank1 { }
internal sealed class __ArrayMapRank2 { }
internal sealed class __ArrayMapRank3 { }

// One trio per non-aliased, non-generic peer:
[assembly: TypeMap<__ArrayMapRank1>("java/lang/String", typeof(string[]),     typeof(string[]))]
[assembly: TypeMap<__ArrayMapRank2>("java/lang/String", typeof(string[][]),   typeof(string[][]))]
[assembly: TypeMap<__ArrayMapRank3>("java/lang/String", typeof(string[][][]), typeof(string[][][]))]
  • Trim target = the closed array type itself (3rd ctor arg). ILC drops the entry when that array shape is never constructed — exact per-shape trimming, validated with disjoint type sets at ~/Projects/playground/TestTypeMapArrays:

    Element-type usage in app T[] typemap entry under NativeAOT
    Reference type used scalar only (new T()) dropped
    Reference type used as new T[…] kept
    Value type used scalar only dropped
    Value type used as new T[…] kept
    Type never referenced anywhere dropped

    Per-shape trimming works for both reference and value-type elements — the test had to use disjoint type sets per group to get clean results (sharing types across multiple groups can cross-pollinate the trim graph and inflate kept-set).

  • Element-only JNI keys avoid the "[" + "L" + jni + ";" runtime concat. The JNI name is already cached by the existing _proxyCache lookup.

  • Three separate groups per rank instead of a single composite key. Lookup is a switch on rank inside TryGetArrayType; rank is recursion depth from elementType.IsArray walk.

  • Skip rules unchanged from the speculative-emission proposal: open generics (typeof(T<>[]) is invalid), JNI primitive-keyword keys (Z, B, …; primitives go through the legacy GetPrimitiveArrayTypesForSimpleReference path), alias groups (would produce duplicate keys; deferred).

CoreCLR-targeted builds emit no array entries at all — saves ~900 KB of attribute metadata in the typemap assembly for a typical MAUI app.

Runtime helper

TrimmableTypeMap.TryGetArrayType(Type elementType, out Type? arrayType):

  1. Walk down elementType.IsArray / GetElementType() to find the leaf element type and the array depth (byte[][] → leaf byte, depth 2; depth + 1 = total rank requested).
  2. Resolve the leaf JNI element name:
    • Primitive → static dictionary (bool→"Z", byte→"B", char→"C", short→"S", int→"I", long→"J", float→"F", double→"D").
    • Reference → TryGetJniNameForManagedType(leaf, out var jni).
  3. Switch on rank to pick the right __ArrayMapRank{N} dictionary.
  4. dict.TryGetValue(elementJni, out arrayType).

PR #11238 already ships scaffolding for this (the new ITypeMapWithAliasing.TryGetType, TrimmableTypeMap.TryGetArrayType shell, primitive-encoding helper) so the eventual full implementation only needs to flip a few switches.

Considered alternatives

Proxy map (TypeMapAssociationAttribute<TGroup>) keyed by element Type → array Type. Cleaner runtime API (Type → Type direct, no TryGetJniNameForManagedType indirection) and the generator wouldn't need to compute element JNI names for these entries. Rejected because it under-trims: ProxyTypeMapNode.GetConditionalStaticDependencies conditions on MaximallyConstructableType(key) (the element type), so the array entry is alive whenever the element type is reachable. Disjoint-set NativeAOT test:

Usage of element T External map (TypeMap<G>, trim target = T[]) Proxy map (TypeMapAssociation<G>, trim trigger = T)
Ref scalar only T[] dropped T[] kept
Ref scalar + array T[] kept T[] kept
Struct scalar only T[] dropped T[] kept
Struct scalar + array T[] kept T[] kept
Never referenced T[] dropped T[] dropped (refs) / kept (structs — typeof in attribute marks MaximallyConstructable for value types)

For Mono.Android peer types specifically (all reference types, many constructed scalarly without ever appearing as T[]), the proxy-map approach would keep ~3× more entries alive than the external map. The runtime API simplification is real but doesn't justify the size regression.

Single composite-key external map ("[L<jni>;", "[[L<jni>;", …) in one group. Rejected — same trimming behaviour as the per-rank groups, but pays a string.Concat at every JNIEnv.ArrayCreateInstance call site. Per-rank groups eliminate the concat by making the rank a switch dispatch instead of part of the key.

Scanner-based emission (only emit array entries for peers actually used as T[] in binding signatures). Tighter result than speculative emission. Deferred — requires the metadata signature walker we skipped earlier (rubber-duck noted in the original PR planning). Layerable on top of the speculative design without breaking the runtime contract; planned as a follow-up if app-size measurements show the speculative cost is too high.

Why this is unblocked from the recent ILLink fix

dotnet/runtime#126380 (closing dotnet/runtime#126177) fixes TypeMapHandler.RecordTypeMapEntry / MarkTypeMapAttribute so they no longer crash on Mono.Cecil.ArrayType. Without this fix, ILLink throws NotSupportedException: TypeDefinition cannot be resolved from 'Mono.Cecil.ArrayType' type and the build fails. The fix ships in .NET 11 nightly 11.0.100-preview.5.26228.123 and later. PR #11238 is gated on dotnet/android picking up an SDK with this fix.

Note: even with #126380, ILLink still trims at element-type granularity (per the design choice in that PR). That's why we use a runtime fork and emit array entries only for NativeAOT builds — there's no benefit to paying the attribute cost when the consumer (CoreCLR) can do Array.CreateInstance natively anyway.

Removed by Phase 2

JavaPeerContainerFactory<T>.CreateArray and CreateHigherRankArray (and the abstract base method). The CoreCLR branch (Array.CreateInstance) replaces both — and additionally supports unlimited rank because the runtime type loader can construct any T[N] dynamically. NativeAOT remains capped at the emitted ranks (1–3).

Phase 3: Delete JavaPeerContainerFactory

Once Phase 1 and Phase 2 are done:

  • Delete JavaPeerContainerFactory and JavaPeerContainerFactory<T>
  • Delete JavaPeerProxy.GetContainerFactory() virtual + JavaPeerProxy<T> override
  • Generated proxies no longer instantiate JavaPeerContainerFactory<T>
  • All per-type bloat eliminated

Action items

  • Phase 1 PR: Replace CreateList/CreateCollection/CreateDictionary with typemap lookup + ActivateUsingReflection
  • Phase 2 PR: [TrimmableTypeMap] Improve array handling #11238 — scaffolding merged with runtime helpers in place; full implementation (per-rank typemap groups, generator emission gated on $(PublishAot), runtime fork on IsDynamicCodeSupported, factory deletion) lands once dotnet/android picks up an SDK with dotnet/runtime#126380
  • Phase 3 PR: Delete JavaPeerContainerFactory and related virtual methods
  • Measurement: NativeAOT app size before/after each phase on a representative MAUI app

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    copilot`copilot-cli` or other AIs were used to author thistrimmable-type-map

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions