Skip to content

[TrimmableTypeMap] Improve array handling#11238

Draft
simonrozsival wants to merge 2 commits intodev/simonrozsival/trimmable-typemap-javacastfrom
dev/simonrozsival/trimmable-array-typemap
Draft

[TrimmableTypeMap] Improve array handling#11238
simonrozsival wants to merge 2 commits intodev/simonrozsival/trimmable-typemap-javacastfrom
dev/simonrozsival/trimmable-array-typemap

Conversation

@simonrozsival
Copy link
Copy Markdown
Member

@simonrozsival simonrozsival commented Apr 28, 2026

Summary

Replaces the per-T JavaPeerContainerFactory<T>.CreateArray factory with a runtime fork that uses the .NET runtime type loader on CoreCLR/Mono and a per-rank trimmable typemap on NativeAOT. Eliminates the per-peer array-creation IL bloat that JavaPeerContainerFactory<T> was contributing — without the trim regressions we evaluated through several alternative designs (see Considered alternatives at the end).

Tracking: #11234 Phase 2 (arrays-only — container types JavaList<T> / JavaCollection<T> / JavaDictionary<K, V> stay untouched and have their own follow-up PR).

Runtime fork

JNIEnv.ArrayCreateInstance branches on RuntimeFeature.IsDynamicCodeSupported:

if (RuntimeFeature.TrimmableTypeMap) {
    if (System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) {
        // CoreCLR / Mono — runtime type loader can construct any T[] dynamically.
        // No typemap roundtrip; supports unlimited array rank.
        return Array.CreateInstance (elementType, length);
    }
    // NativeAOT — resolve via per-rank trimmable typemap +
    // AOT-safe Array.CreateInstanceFromArrayType.
    if (TrimmableTypeMap.Instance.TryGetArrayType (elementType, out var arrayType)) {
        return Array.CreateInstanceFromArrayType (arrayType, length);
    }
    throw new NotSupportedException ("...");
}
// legacy non-trimmable fallback unchanged

Why fork? CoreCLR's runtime type loader can construct any T[] from its element-type metadata at runtime, so going through the typemap is unnecessary work and trim warnings. NativeAOT cannot (value-type arrays each need their own codegen, and our marshaling call sites are opaque to the trimmer), so the typemap is the only AOT-safe route.

Generator changes

For every non-aliased, non-generic peer (excluding JNI primitive-keyword keys), the generator emits three speculative TypeMap entries keyed by the element JNI name and anchored to per-typemap-assembly rank sentinel TypeDefs:

// Inside each per-assembly typemap dll, when $(PublishAot) == true:
internal sealed class __ArrayMapRank1 { }
internal sealed class __ArrayMapRank2 { }
internal sealed class __ArrayMapRank3 { }

[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 = closed array type so ILC's per-shape conditional drops the entry when the array shape is never constructed (validated at ~/Projects/playground/TestTypeMapArrays with disjoint type sets — works for both ref- and value-type elements).
  • Element-only JNI keys — no "[" + "L" + jni + ";" runtime concat.
  • Per-rank groups — rank is a switch dispatch at runtime, not part of the key. The __ArrayMapRank{N} sentinels mirror the existing __TypeMapAnchor pattern (per-assembly, like generic anchor types).
  • Conditional emission gated on $(PublishAot) == true. Saves attribute metadata on CoreCLR-only builds where the runtime fork bypasses the typemap entirely.
  • Skip rules: open-generic peers (typeof(T<>[]) invalid), JNI primitive-keyword keys (handled by the legacy primitive-array path), alias groups (would produce duplicate keys; deferred until alias-aware design).

Runtime API

ITypeMapWithAliasing gains:

bool TryGetArrayType (string jniElementTypeName, int rank, [NotNullWhen (true)] out Type? arrayType);

SingleUniverseTypeMap carries three nullable IReadOnlyDictionary<string, Type>? fields (rank 1, 2, 3); AggregateTypeMap does first-wins iteration. TrimmableTypeMap.Initialize gains 5-arg overloads (single + aggregate) accepting the per-rank dicts; the existing 2-arg overloads stay as wrappers passing null per-rank dicts.

TrimmableTypeMap.TryGetArrayType(Type elementType, out Type? arrayType) walks down elementType.IsArray / GetElementType() to find the leaf type and the element array depth, resolves the leaf JNI element name (primitive static dict OR TryGetJniNameForManagedType wrapped), and delegates the (elementJni, rank=elementDepth+1) lookup to _typeMap.TryGetArrayType.

Generator IL emission

RootTypeMapAssemblyGenerator.EmitTypeMapLoader branches on emitArrayEntries. When true, the generated TypeMapLoader.Initialize IL collects per-rank dicts from each per-assembly typemap (5 IReadOnlyDictionary<...>[] arrays total — typeMaps, proxyMaps, arrayMapsRank1/2/3) and passes them to the 5-arg TrimmableTypeMap.Initialize. The aggregate (Debug) path is fully implemented; the merged-universe (Release) path throws at generation time with a clear message — wiring the shared-universe array sentinels is a small follow-up.

What's deleted

  • JavaPeerContainerFactory<T>.CreateArray and CreateHigherRankArray.
  • The abstract base method on JavaPeerContainerFactory.

Other factory methods (CreateList, CreateCollection, CreateDictionary*) stay — those will be addressed in the follow-up Phase 2-collections PR.

Files changed

File Notes
Generator/Model/TypeMapAssemblyData.cs New TypeMapAttributeData.AnchorRank (1-based per-rank anchor) and TypeMapAssemblyData.RankSentinels (sentinel TypeDef names).
Generator/TypeMapAssemblyEmitter.cs EmitRankSentinels mirrors EmitAnchorType; per-anchor TypeMap<TGroup> 3-arg ctor caching via GetOrAddTypeMapAttr3ArgCtorRef.
Generator/ModelBuilder.cs New bool emitArrayEntries parameter on Build; per-rank trio emitted via new EmitArrayEntries (skip rules: generics, primitive keywords, aliases).
Generator/RootTypeMapAssemblyGenerator.cs New bool emitArrayEntries parameter; aggregate path emits per-rank dict arrays in TypeMapLoader.Initialize; new 5-arg Initialize member refs.
TrimmableTypeMapGenerator.cs Execute and GenerateTypeMapAssemblies propagate emitArrayEntries.
Generator/TypeMapAssemblyGenerator.cs Generate propagates emitArrayEntries.
Tasks/GenerateTrimmableTypeMap.cs New EmitArrayEntries MSBuild task property.
targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets Sets EmitArrayEntries="$(PublishAot)" on the task invocation.
Microsoft.Android.Runtime/ITypeMapWithAliasing.cs TryGetArrayType interface method.
Microsoft.Android.Runtime/SingleUniverseTypeMap.cs Per-rank dict ctor + TryGetArrayType impl.
Microsoft.Android.Runtime/AggregateTypeMap.cs First-wins TryGetArrayType impl.
Microsoft.Android.Runtime/TrimmableTypeMap.cs New 5-arg Initialize overloads + TryGetArrayType(Type, out Type?) + TryGetPrimitiveJniName helper.
Android.Runtime/JNIEnv.cs ArrayCreateInstance runtime fork.
Java.Interop/JavaPeerContainerFactory.cs CreateArray / CreateHigherRankArray deleted; class doc updated.
tests/.../TypeMapModelBuilderTests.cs 14 new generator unit tests covering default-off behavior, sentinel emission, per-rank trio, element-only key, closed-array trim target, conditional-only entries, skip rules (open generic, alias group, primitive keywords), multi-peer isolation, and PE blob round-trip.

Validation

  • Generator unit tests: 445 / 445 pass (425 baseline + 20 new).
  • Trimmable + CoreCLR RunTestApp lane on emulator (-p:_AndroidTypeMapImplementation=trimmable -p:UseMonoRuntime=false): 917 total, 0 errors, 3 failures (the 3 are pre-existing TryGetJniNameForManagedType_* from [TrimmableTypeMap] JavaCast/JavaAs + container support #11225, called out as out-of-scope there). No regression.
  • NativeAOT path with array TypeMap entries: validated with the standalone repro at ~/Projects/playground/TestTypeMapArrays against .NET 11 nightly 11.0.100-preview.5.26228.123 (which bundles [ILLink/ILCompiler] Fix crash when array types are used as TypeMap trim targets runtime#126380's TypeMapHandler.UnwrapToResolvableType fix). The dotnet/android end-to-end NativeAOT lane needs the SDK to ship a build with #126380 — currently in our SDK we have 11.0.0-preview.4.26215.121 which crashes ILLink before reaching the new code.

SDK gating

The NativeAOT array-emission path requires dotnet/runtime#126380 (TypeMapHandler.UnwrapToResolvableType, merged 2026-04-20, ships in .NET 11 nightly preview.5+). Until dotnet/android picks up an SDK with that fix:

  • CoreCLR / Mono — works today. The runtime fork hits Array.CreateInstance directly; the typemap isn't consulted.
  • NativeAOT with _AndroidTypeMapImplementation=trimmable — would crash in ILLink's TypeMapHandler.RecordTypeMapEntry on the [assembly: TypeMap<__ArrayMapRank{N}>(...)] array entries. Not in any current dotnet/android CI lane (the typical NativeAOT lanes use Mono runtime or don't enable trimmable typemap), so the PR can land without blocking on the SDK bump. Once the SDK bumps, the path becomes live with no further changes.

Considered alternatives

Documented in detail in #11234. Summary:

  • Proxy map (TypeMapAssociation<G>(typeof(T), typeof(T[]))): cleaner Type → Type lookup, but ProxyTypeMapNode.GetConditionalStaticDependencies conditions on MaximallyConstructableType(key) (the element), so T[] survives whenever T is reachable — losing per-shape trimming. Validated on disjoint-set NativeAOT tests.
  • Single composite-key external map ("[L<jni>;", "[[L<jni>;"): same trim behavior as per-rank groups but pays a string.Concat at every JNIEnv.ArrayCreateInstance call. Per-rank groups eliminate that cost.
  • Scanner-based emission: tighter trim than speculative emission, but requires a metadata signature walker. Layerable on top of this PR as a follow-up if app-size measurement shows speculative emission is too costly.

Out of scope

  • Container types (JavaList<T> / JavaCollection<T> / JavaDictionary<K, V>) and the corresponding JavaPeerContainerFactory<T>.Create* methods.
  • JavaPeerProxy<T>.GetContainerFactory() virtual / override (deletion follows in the Phase 3 PR).
  • TrimmableTypeMap.GetContainerFactory(Type).
  • Removing [DAM(Constructors)] annotations ([TrimmableTypeMap] Address all trimming and AOT warnings #10794).
  • Alias-aware array emission.
  • Merged-universe (Release) array emission — generator throws at generation time today; small follow-up to wire the shared-universe loader.

@simonrozsival simonrozsival changed the base branch from main to dev/simonrozsival/trimmable-typemap-javacast April 28, 2026 15:19
@simonrozsival simonrozsival changed the title [TrimmableTypeMap] Replace per-T array factory with TypeMap entries [TrimmableTypeMap] Add array-typemap scaffolding (blocked on ILLink fix) Apr 28, 2026
@simonrozsival simonrozsival added copilot `copilot-cli` or other AIs were used to author this trimmable-type-map labels Apr 29, 2026
simonrozsival and others added 2 commits April 29, 2026 10:54
For every non-aliased, non-generic peer (excluding JNI primitive-keyword
keys), the generator can now emit three speculative `TypeMap<T>` entries
keyed by the **element JNI name** and anchored to per-typemap-assembly
`__ArrayMapRank{1,2,3}` sentinel TypeDefs:

  [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[][][]))]

The trim target is the closed array type itself, so ILC's per-shape
conditional drops entries when the array shape is never constructed —
validated on disjoint-set NativeAOT tests for both reference and value
type element peers.

* `Generator/Model/TypeMapAssemblyData.cs`:
  * `TypeMapAssemblyData.RankSentinels` (nullable `RankSentinelNames`)
    — when set, the emitter generates the three sentinel TypeDefs.
  * `TypeMapAttributeData.AnchorRank` (nullable int) — when set,
    overrides the model-level default anchor with the rank-{value}
    sentinel from the same assembly.

* `Generator/TypeMapAssemblyEmitter.cs`:
  * `EmitRankSentinels` mirrors the existing `__TypeMapAnchor` pattern.
  * Per-anchor `TypeMap<TGroup>` 3-arg ctor refs are now built and
    cached lazily by anchor handle (`GetOrAddTypeMapAttr3ArgCtorRef`).
  * `EmitTypeMapAttribute` resolves rank-anchored entries to the local
    sentinel handle.

* `Generator/ModelBuilder.cs`:
  * `Build` takes a new `bool emitArrayEntries` (default false).
    When true, sets `RankSentinels` and routes each peer through
    `EmitArrayEntries`, which produces the per-rank trio.
  * Skip rules: open-generic peers, JNI primitive-keyword keys, alias
    groups (would produce duplicate keys; deferred).

* `tests/.../TypeMapModelBuilderTests.cs`: 14 new tests covering the
  default-off behavior, default sentinel names, per-rank-trio emission,
  element-only key, closed-array trim target, conditional-only entries,
  open-generic / alias / primitive-keyword skip rules, multiple-peer
  isolation, and PE blob round-trip (sentinels emitted, attribute
  blobs survive).

Tracking: #11234 Phase 2 (arrays-only, container types stay untouched).
Runtime wiring + MSBuild flag follow in subsequent commits.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`JNIEnv.ArrayCreateInstance` now branches on
`RuntimeFeature.IsDynamicCodeSupported`:

* CoreCLR / Mono (true) — `Array.CreateInstance(elementType, length)`.
  No typemap roundtrip; supports unlimited array rank.
* NativeAOT (false) — typemap lookup &#8594; AOT-safe
  `Array.CreateInstanceFromArrayType`. Capped at the emitted ranks
  (1–3); miss throws `NotSupportedException` with diagnostic.

The runtime fork lets us avoid emitting (and paying for) speculative
array TypeMap entries on CoreCLR-only builds, where the runtime type
loader can construct any `T[]` dynamically anyway.

* `ITypeMapWithAliasing.TryGetArrayType(string jniElementTypeName,
  int rank, out Type? arrayType)` — new abstraction for the per-rank
  array dictionary lookup. `SingleUniverseTypeMap` carries three
  nullable `IReadOnlyDictionary<string, Type>?` fields (rank 1, 2, 3)
  populated at `TrimmableTypeMap.Initialize` time;
  `AggregateTypeMap` does first-wins iteration.

* `TrimmableTypeMap.TryGetArrayType(Type elementType, out Type?)` —
  walks down `elementType.IsArray` / `GetElementType()` to find the
  leaf type and array depth, resolves the leaf JNI element name
  (primitive static dict OR `TryGetJniNameForManagedType` wrapped),
  and delegates the (jni, rank+1) lookup to the interface.

* `TrimmableTypeMap.Initialize` gains 5-arg overloads (single +
  aggregate) accepting the per-rank dicts. Existing 2-arg overloads
  stay as wrappers passing null per-rank dicts so older generated
  assemblies keep working.

* `RootTypeMapAssemblyGenerator`: the generated `TypeMapLoader.Initialize`
  IL now branches on the new `emitArrayEntries` flag. When true, it
  collects per-assembly `__ArrayMapRank{1,2,3}` sentinels via
  `TypeMapping.GetOrCreateExternalTypeMapping<__ArrayMapRank{N}>()` and
  passes the resulting dicts to the 5-arg `TrimmableTypeMap.Initialize`.
  Aggregate (Debug) path is fully implemented; merged-universe (Release)
  path throws at generation time with a clear message — wiring the
  shared-universe array sentinels is a small follow-up.

* `GenerateTrimmableTypeMap` MSBuild task: new `EmitArrayEntries`
  property forwarded through `TrimmableTypeMapGenerator.Execute` and
  `RootTypeMapAssemblyGenerator.Generate`. SDK target sets it to
  `$(PublishAot)`.

* `JavaPeerContainerFactory<T>.CreateArray` and
  `CreateHigherRankArray` deleted. Container methods (`CreateList`,
  `CreateCollection`, `CreateDictionary*`) stay untouched — those
  are tracked separately in #11234.

Validation:

* 445 / 445 generator unit tests pass.
* Trimmable + CoreCLR `RunTestApp` lane on emulator:
  **917 total / 0 errors / 3 failures** (pre-existing
  `TryGetJniNameForManagedType_*`, called out as out-of-scope in
  #11225). No regression.
* The NativeAOT branch path is gated on dotnet/runtime#126380 (ships
  in .NET 11 nightly preview.5+); validated with the playground repro
  separately.

Tracking: #11234 Phase 2 (arrays only).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival simonrozsival force-pushed the dev/simonrozsival/trimmable-array-typemap branch from 5b32352 to 956a999 Compare April 29, 2026 09:28
@simonrozsival simonrozsival changed the title [TrimmableTypeMap] Add array-typemap scaffolding (blocked on ILLink fix) [TrimmableTypeMap] Replace per-T array factory with runtime fork + per-rank typemap Apr 29, 2026
@simonrozsival simonrozsival changed the title [TrimmableTypeMap] Replace per-T array factory with runtime fork + per-rank typemap [TrimmableTypeMap] Improve array handling Apr 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant