Skip to content

TypeMap API: For trimmed and nativeaot builds, provide a list of the types kept by the typemap #120204

Description

@jtschuster

In Android (and maybe iOS), it is necessary to create Java wrapper types and native glue stubs that call into .NET. It's not necessary to keep these java types and native methods for .NET types that are trimmed away, but there's not an easy way to tell what is necessary for the app. If the TypeMap output the target types in the typemap that are kept, it would greatly simplify and speed up the build process, and create smaller applications.
Alternatively, the android build could inspect every single assembly in trimmed apps, and parse the ILC trimming xml logger for nativeaot apps.

Open questions:

Android works around one Java class mapping to multiple .NET types with a "mapper class" in the TypeMap that points to each of the aliases. This would require additional bookkeeping in the build to remember which types are mapper classes and which java classes and stubs they should keep for those mapper types.

cc. @simonrozsival @AaronRobinsonMSFT

Activity

  1. added this to the 11.0.0 milestone on Sep 29, 2025
  2. AaronRobinsonMSFT commented on Sep 29, 2025

    @AaronRobinsonMSFT
    Member

    If the TypeMap output the target types in the typemap that are kept

    @jtschuster This isn't unreasonable as a build artifact from the tooling. I would expect this to be next to the assembly in the final output directory. I'd like to hear from @agocke and @MichalStrehovsky as well regarding the format/semantics.

  3. agocke commented on Sep 30, 2025

    @agocke
    Member

    If the TypeMap output the target types in the typemap that are kept, it would greatly simplify and speed up the build process, and create smaller applications.

    How much of an improvement are we talking about? That would set a good ballpark for the benefit.

    cc @sbomer

  4. simonrozsival commented on Nov 13, 2025

    @simonrozsival
    Member

    I don't have the exact number at the moment, but we are talking about several hundreds of java classes + corresponding native code which would be packed in the app and not used.

    I believe this is not a blocking issue for PoC. Once we have a PoC, we will have exact numbers and we can evaluate, if this is worth the effort and added complexity.

  5. rolfbjarne commented on Feb 4, 2026

    @rolfbjarne
    Member

    This will be necessary for iOS, for a few of reasons:

    1. The native linker will trim dead code, which means that if a managed project references a third-party binding project, but doesn't actually use all of the native code from that binding project, then the native linker will remove that unused native code. This can have a snowballing effect on trimmed apps; which we wouldn't be taking advantage of because we'd keep a strong reference to all native types declared in that third-party binding project.
    2. We depend on the trimmer to trim away code that would otherwise trigger additional App Store requirements. For instance, using the HealthKit API requires an entitlement to do so, and when an app is uploaded to the App Store, the App Store will validate that the app has the HealthKit entitlement if the app links with the HealthKit framework. This means that we can't link in any native code that references the HealthKit framework (unless the app actually wants to use the HealthKit framework); and the typemap will contain types from the HealthKit framework.
  6. agocke commented on Feb 4, 2026

    @agocke
    Member
  7. agocke commented on Feb 4, 2026

    @agocke
    Member

    @rolfbjarne are you only referring to il trimming here? Native aot should also remove all unreachable pinvokes, so I don’t see how an additional list is necessary or useful

  8. rolfbjarne commented on Feb 5, 2026

    @rolfbjarne
    Member

    @rolfbjarne are you only referring to il trimming here? Native aot should also remove all unreachable pinvokes, so I don’t see how an additional list is necessary or useful

    Before trimming (either by ILLink or NativeAOT) we'll generate supporting native code for a number of C# classes.

    The question is how to not include native code for C# classes that were trimmed away in the app, when we don't necessarily know which C# classes were trimmed away (NativeAOT).

    It might be possible to use DirectPInvoke for all P/Invokes, link with all the supporting native code, and then just have the native linker remove any supporting native code that's not referenced. This will only work if NativeAOT doesn't root every DirectPInvoke though. It'll also only work if the app doesn't depend on any custom native library loading behavior, and doesn't have P/Invokes to methods that don't exist.

    There's also the fact that this process is wasteful: we'll generate native code (and compile it, and link with it) for a number of C# classes that will be trimmed away, solely because we don't know which C# classes survives trimming. Ideally we'll only be generating native code for C# classes that survives trimming.

  9. agocke commented on Feb 14, 2026

    @agocke
    Member
  10. MichalStrehovsky commented on Feb 16, 2026

    @MichalStrehovsky
    Member

    Here's how I think this can work, correct me if my understanding is not right:

    1. Some C# classes require extra native code to work. If the p/invoke to the extra native code is unreachable, the native code is not needed.
      • When we're doing IL-level trimming with ILLink, there is a pre-existing switch to generate list of all p/invoke definitions that survived trimming (added in Add the command line option --output-pinvokes linker#992)
      • When we're doing native AOT, there is a mechanism called DirectPInvoke where we can instruct the compiler to generate certain LibraryName!Entrypoint pairs (this comes from [DllImport("LibraryName", EntryPoint = "EntryPoint")]) as "direct P/invokes". This means the compiler will generate a simple unresolved symbol reference to EntryPoint that the native linker or loader needs to resolve.
    2. (The name of the DllImport can be customized so that it's unique and fits our purposes. For native AOT with p/invokes, the name in DllImport doesn't matter after compiler. For JIT scenarios it matters for binding, but can be remapped with NativeLibrary.SetDllImportResolver.)
      • When we're doing IL-level trimming, we can use the list of necessary p/invokes to generate a linker script for the native linker so that only native symbols referenced from managed p/invokes are generated. Dead code elimination in native linker will discard unused p/invokes in the native library.
      • When we're doing native AOT, native linker will match unresolved symbols from managed code with symbols in the native code. Dead code elimination will remove unused native code.
  11. rolfbjarne commented on Feb 16, 2026

    @rolfbjarne
    Member

    This would work, but in any case ILLink is not a problem, because we can inspect the trimmed assembly.

    • When we're doing native AOT, native linker will match unresolved symbols from managed code with symbols in the native code. Dead code elimination will remove unused native code.

    This won't solve the problem of doing unnecessary work; we're generating (and compiling) native code for the native linker just to throw it away.

    One option that might work is to get all the external/unresolved symbols from NativeAOT's native output (by parsing the Mach-O file), and use that to determine the native code that needs to be generated (like your ILLink proposal). This has the additional advantage that the same code path can be used to generate native code for both ILLink and NativeAOT, since the input is the same - a list of p/invokes that survived trimming.

  12. MichalStrehovsky commented on Feb 16, 2026

    @MichalStrehovsky
    Member

    This would work, but in any case ILLink is not a problem, because we can inspect the trimmed assembly.

    Inspecting the assembly always comes with risks that the thing that is found is not actually usable. For example, ILLinker might keep a type because it had to keep a field with this type. But the type was never used or instantiated. So what's left after trimming is just a skeleton and not a usable type in any way. It's better to define contracts, such as the --output-pinvokes contract.

    One option that might work is to get all the external/unresolved symbols from NativeAOT's native output

    That would probably work too. But I would still let the native linker do the dead code removal. If the generation of native interop code is incremental, I'd expect that in practice we'd only see it generated once on the first build. If on the other hand it depends on what was left after trimming, the incremental logic becomes a lot more complicated.

  13. simonrozsival commented on May 22, 2026

    @simonrozsival
    Member

    I have concrete numbers now from a dotnet/android HelloWorld NativeAOT experiment. I think this is worth implementing.

    Today, Android can get similar information by enabling NativeAOT DGML output and parsing the resulting files. That is probably acceptable for .NET 11, where NativeAOT support in dotnet/android is still experimental/preview, but it is not a great long-term contract. For this tiny HelloWorld app, the scan DGML is already ~23 MB, and I would expect it to be even larger for real-world apps. A focused typemap output would avoid scraping diagnostic graph data, reduce IO overhead, and likely help build times.

    The table below compares the current behavior, where generated Java wrappers are not trimmed based on post-ILC typemap reachability, against a prototype that uses DGML-derived reachability information to generate a narrower R8/ProGuard keep set. The native .so is unchanged, so the useful signal is Java/dex/APK size.

    Metric HelloWorld w/o ProGuard Java trimming HW w/ ProGuard (DGML based) Delta
    APK size 3,638.4 kB 3,572.8 kB -65.5 kB (-1.80%)
    classes.dex size 259.4 kB 15.2 kB -244.2 kB (-94.15%)
    compressed classes.dex in APK 72.2 kB 7.0 kB -65.3 kB (-90.35%)
    Java class count 364 21 -343 (-94.23%)
    Java method count 3,035 155 -2,880 (-94.89%)

    This is only a HelloWorld app, so the absolute APK win is modest, but it demonstrates the shape of the benefit: hundreds of generated Java wrapper classes can be removed once we know which typemap entries survived trimming. For larger apps with more bindings/wrappers, I would expect the absolute difference to be larger.

    Full HelloWorld scan DGML and generated ProGuard configs from the experiment are here: https://gist.github.com/simonrozsival/6e47a53943c22cc4919696196e61c0b5

    Prototype DGML-to-ProGuard algorithm

    The prototype uses generic retained managed type metadata from the NativeAOT scan DGML and intersects it with Android's acw-map.txt.

    1. Enable NativeAOT DGML output with IlcGenerateDgmlFile=true.

    2. After IlcCompile, read $(NativeIntermediateOutputPath)$(TargetName).scan.dgml.xml.

    3. Read retained managed type metadata nodes whose labels have this shape:

      Type metadata: [AssemblyName]Namespace.TypeName
      
    4. Build a HashSet<string> of retained managed type keys, using both forms:

      Namespace.TypeName
      Namespace.TypeName, AssemblyName
      
    5. Read $(IntermediateOutputPath)acw-map.txt, which maps managed type keys to Java class names:

      Managed.Type.Name;generated.java.ClassName
      Managed.Type.Name, AssemblyName;generated.java.ClassName
      
    6. Intersect the retained managed type keys with the managed side of acw-map.txt, then emit concrete R8/ProGuard keep rules for the corresponding Java class names:

      -keep class generated.java.ClassName { *; }
      

    The DGML does contain TypeMap-specific information, for example:

    External type map: [Mono.Android]Java.Lang.Object, _ZTV41Java_Interop_Java_Interop_JavaProxyObject
    Proxy type map: [Mono.Android]Java.Lang.Object, _ZTV44Java_Interop_Java_Interop_JavaProxyThrowable constructed
    

    This is useful for investigation, but not an ideal Android build contract as-is: the records expose ILC graph/vtable node names, not the surviving external TypeMap keys in a directly consumable form. For example, deriving [Mono.Android]Java.Lang.StringBuffer from _ZTV35Mono_Android_Java_Lang_StringBuffer is possible in simple cases, but it means decoding NativeAOT symbol shapes instead of consuming the original external TypeMap key.

    What a focused typemap output could look like

    For Android R8/ProGuard generation, the ideal output would be a post-trim/post-ILC list of surviving external TypeMap keys:

    example/MainActivity
    mono/samples/hello/LibraryActivity
    android/runtime/JavaProxyThrowable
    

    Android can turn each surviving external key into a concrete ProGuard keep rule with a trivial JNI-name-to-Java-name transform:

    example/MainActivity -> example.MainActivity
    mono/samples/hello/LibraryActivity -> mono.samples.hello.LibraryActivity
    android/runtime/JavaProxyThrowable -> android.runtime.JavaProxyThrowable
    foo/bar/Outer$Inner -> foo.bar.Outer.Inner
    

    and then emit:

    -keep class example.MainActivity { *; }
    

    So Android would no longer need to parse a large DGML file or intersect generic retained managed metadata with acw-map.txt. It could consume the surviving external TypeMap keys directly.

    My current thinking is:

    • use the DGML-based approach in dotnet/android for .NET 11 while NativeAOT support is experimental/preview;
    • target a focused typemap-survivor output for .NET 12, when NativeAOT support should be fully released.
  14. sbomer commented on Aug 3, 2026

    @sbomer
    Member

    We have working solutions for .NET 11 (DGML for Android, exported symbols for mac/iOS). We should discuss designs that would satisfy both Android and mac/iOS early in .NET 12.

  15. modified the milestones: 11.0.0, 12.0.0 on Aug 3, 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

    area-Tools-ILLink.NET linker development as well as trimming analyzers

    Type

    No type

    Projects

    • Status
      No status

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions