Skip to content

[NativeAOT] Trim unused typemap Java code in Release#12228

Open
simonrozsival wants to merge 1 commit into
mainfrom
dev/simonrozsival/nativeaot-trim-typemap-java
Open

[NativeAOT] Trim unused typemap Java code in Release#12228
simonrozsival wants to merge 1 commit into
mainfrom
dev/simonrozsival/nativeaot-trim-typemap-java

Conversation

@simonrozsival

@simonrozsival simonrozsival commented Jul 24, 2026

Copy link
Copy Markdown
Member

Summary

  • enable DGML-filtered R8 keep rules by default for optimized NativeAOT trimmable-typemap builds
  • emit only the scan graph in optimized builds or the codegen graph in unoptimized explicit opt-ins, instead of always producing both
  • serialize graph-producing multi-RID NativeAOT builds to avoid system file-table exhaustion
  • stop DGML parsing after the Nodes section and add runtime-host/incremental coverage

Context

Related to #12184.

The performance-chart discontinuity is separately addressed by dotnet/performance#5265, but the APK investigation found real Java-side over-retention. With Java trimming disabled, R8 keeps all 13,007 Java names from acw-map.txt, even though ILC retains only 1,132 typemap records for this app.

For dotnet new maui --sample-content, android-arm64, Release NativeAOT:

Metric Keep all Java types Filtered keep rules Delta
Signed APK 21,876,685 B 18,931,603 B -2,945,082 B
Raw DEX 13,626,844 B 5,171,592 B -8,455,252 B
Java keep rules 13,007 1,154 -11,853
NativeAOT shared library 32,019,032 B 32,019,032 B 0 B

The removed Java closure includes all 2,253 Tink classes, AndroidX Security Crypto, and thousands of unused AndroidX/Kotlin/Material classes.

DGML and build impact

The previous opt-in used IlcGenerateDgmlFile=true, which emitted both graphs:

  • scan DGML: 545,822,343 B
  • codegen DGML: 426,448,469 B
  • total: 972,270,812 B

This change requests only the graph needed by the current compilation mode. A normal optimized build now writes only the 545,822,343-byte scan graph. Explicit IlcGenerateDgmlFile=true still produces both diagnostic graphs.

Two warm-cache clean runs per mode on macOS arm64 measured:

Mode Mean wall time Mean peak RSS DGML
Java trimming off 137.03 s 2.29 GB 0 B
Previous full-DGML opt-in 136.53 s 3.15 GB 972,270,812 B
This change 128.07 s 2.65 GB 545,822,343 B

The wall-clock impact is near-neutral because ILC/report generation is offset by R8 processing a much smaller retained Java graph. The remaining tradeoff is approximately 546 MB of intermediate disk per RID and roughly 0.36 GB additional peak RSS versus keeping all Java types.

No-change publishes remain incremental (about 5-7 seconds): ILC and ProGuard generation skip, and the graph/config/APK timestamps remain unchanged. Source changes rerun ILC, while unchanged keep rules prevent unnecessary javac/R8/APK work.

Correctness

  • Release defaults Java trimming on; Debug remains off because its 790 MB codegen graph produced no Java-size benefit.
  • A true -> false -> source change -> true sequence with a new retained Java peer regenerated the graph and retained the new peer.
  • Multi-RID graph generation is serialized after parallel ILC graph writers reproduced system file-table exhaustion.
  • The sample-content APK installed and launched on an API 36 arm64 emulator and survived 100 Monkey events without a Java class/method failure.

Tests

./dotnet-local.sh test bin/TestDebug/net10.0/Xamarin.Android.Build.Tests.dll \
  --filter FullyQualifiedName~Execute_GenerateNativeAotProguardConfiguration

./dotnet-local.sh test bin/TestDebug/net10.0/Xamarin.Android.Build.Tests.dll \
  --filter FullyQualifiedName~Build_WithTrimmableTypeMap_IncrementalBuild \
  -- NUnit.NumberOfTestWorkers=1

./dotnet-local.sh test bin/TestDebug/net10.0/Xamarin.Android.Build.Tests.dll \
  --filter FullyQualifiedName~Build_WithTrimmableTypeMap_KeepsNativeAotRuntimeHostAcws \
  -- NUnit.NumberOfTestWorkers=1

Why not use IlcGenerateDgmlFile?

The upstream IlcGenerateDgmlFile=true switch expands to both --dgmllog and --scandgmllog. For this sample that writes 426,448,469 bytes of codegen DGML plus 545,822,343 bytes of scan DGML (972,270,812 bytes per RID).

The Android keep-rule task consumes only the scan graph in optimized builds. In unoptimized builds the scanner does not run, so it consumes only the codegen graph. This change passes the required argument directly to avoid generating the other several-hundred-megabyte file.

Explicitly setting IlcGenerateDgmlFile=true continues to request and preserve both graphs for full ILC diagnostics. A cleaner long-term upstream API would expose independent scan/codegen DGML properties.

Enable DGML-filtered R8 keep rules for optimized NativeAOT builds while emitting only the required dependency graph. Preserve Debug behavior, serialize graph-producing multi-RID builds, and improve DGML parsing and incremental coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: a301fa68-0e6c-4280-b2f2-bc3d4c9ae3de
Copilot AI review requested due to automatic review settings July 24, 2026 10:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Improves NativeAOT trimmable-typemap Java shrinking by defaulting to DGML-filtered R8 keep rules for optimized builds, while reducing DGML overhead and making multi-RID builds more reliable.

Changes:

  • Enable DGML-based Java retention filtering by default when $(Optimize)=true, and serialize multi-RID inner builds when DGML generation is active to avoid file-table exhaustion.
  • Emit only the required ILC graph per mode (scan DGML for optimized builds; codegen DGML for unoptimized builds unless full DGML diagnostics are explicitly requested).
  • Optimize DGML parsing by stopping after the <Nodes> section, and extend test coverage for DGML output/incremental behavior.
Show a summary per file
File Description
src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs Adds incremental-build assertions for scan-only DGML output and stable timestamps in optimized NativeAOT builds.
src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs Extends DGML test input to ensure parsing stops at <Nodes> and ignores later sections.
src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeAotProguardConfiguration.cs Stops DGML parsing after <Nodes> and updates messaging to reflect “Java types in ACW map” semantics.
src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets Defaults DGML-filtered Java trimming on for optimized builds, emits only needed DGML, and disables parallel RID inner builds when DGML is produced.

Copilot's findings

  • Files reviewed: 4/4 changed files
  • Comments generated: 0

@jonathanpeppers

Copy link
Copy Markdown
Member

/review

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Android PR Reviewer completed successfully!

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Android PR Review — ⚠️ Minor suggestions (no blocking issues)

Reviewed the 4 files in this PR's diff: the trimmable NativeAOT targets, GenerateNativeAotProguardConfiguration.cs, and the two test files. The change is well-scoped and the reasoning in the comments/description is thorough.

What looks good

  • Defaulting _AndroidTrimmableTypemapTrimJavaCode on for optimized builds (with the documented DEX-size justification) and keeping it off for unoptimized builds is a sensible tradeoff, and the incremental behavior is protected by the _WriteTrimmableNativeAotProguardConfigurationInputs stamp.
  • Requesting only the needed DGML graph (--scandgmllog for optimized, --dgmllog otherwise) instead of IlcGenerateDgmlFile=true's both-graphs behavior is a real ~426 MB/RID saving.
  • Serializing multi-RID inner builds (_AndroidBuildRuntimeIdentifiersInParallel=false) to avoid file-table exhaustion is well-documented.
  • Scoping the DGML reader to the <Nodes> section is a good perf improvement, and the new <Links><Node/></Links> test case is a nice, targeted regression guard. The incremental test's scan-DGML timestamp/codegen.dgml.xml absence assertions are solid.

Suggestions (inline)

  • 💡 Self-closing <Nodes /> edge case in the DGML reader could leave readingNodes stuck true — defensive only.
  • 💡 Confirm the IlcGenerateDgmlFile=true fallback produces DGML filenames that _CollectTrimmableNativeAotDgmlFiles actually matches, otherwise the fallback path may hit XA4319.

Neither is blocking. Note: CI checks were still pending at review time — please confirm the pipeline goes green before merge.

Review posted as COMMENT (non-blocking).

Generated by Android PR Reviewer for #12228 · 104.4 AIC · ⌖ 13.2 AIC · ⊞ 6.8K
Comment /review to run again


bool readingNodes = false;
while (reader.Read ()) {
if (reader.NodeType == XmlNodeType.Element && reader.LocalName == "Nodes") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 💡 JNI interop / Robustness — Scoping the scan to the <Nodes> section is a nice perf win, but a self-closing <Nodes /> element never raises an EndElement, so readingNodes would stay true and any later <Node> (e.g. inside <Links>) would be treated as a real node — exactly the case the new test guards against, just for the empty-section variant. Consider if (reader.IsEmptyElement) continue; (leaving readingNodes false) when you set readingNodes = true. ILC always emits a populated <Nodes> today, so this is defensive only.

scanner does not run. Avoid writing the other several-hundred-megabyte graph unless the
user explicitly requested the full ILC diagnostics. -->
<IlcArg
Condition=" '$(_AndroidTrimmableTypemapTrimJavaCode)' == 'true' and '$(AndroidLinkTool)' != '' and '$(IlcGenerateDgmlFile)' != 'true' and '$(Optimize)' == 'true' "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 💡 MSBuild targets — These explicit --scandgmllog/--dgmllog args are only added when IlcGenerateDgmlFile != 'true'. When a user does set IlcGenerateDgmlFile=true, generation falls back to ILC's own DGML output, but _CollectTrimmableNativeAotDgmlFiles only looks for $(TargetName).scan.dgml.xml / $(TargetName).codegen.dgml.xml. Are you sure ILC's IlcGenerateDgmlFile path emits files with those exact names/locations? If it uses a different name (e.g. $(TargetName).dgml.xml), the collect step could come up empty and trip XA4319 — most likely in an unoptimized build where only the codegen graph exists. Worth a quick confirmation since that path is less exercised than the default.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants