Skip to content

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

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-TypeDesc — string[], 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

Activity

  1. added
    copilot`copilot-cli` or other AIs were used to author this
    and removed
    needs-triageIssues that need to be assigned.
    on Apr 28, 2026
  2. added this to the .NET 11 milestone on Apr 28, 2026
  3. locked and limited conversation to collaborators on Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

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

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions