Skip to content

[TrimmableTypeMap][NativeAOT] Build plumbing fixes#11989

Open
simonrozsival wants to merge 9 commits into
mainfrom
dev/simonrozsival/trimmable-nativeaot-plumbing
Open

[TrimmableTypeMap][NativeAOT] Build plumbing fixes#11989
simonrozsival wants to merge 9 commits into
mainfrom
dev/simonrozsival/trimmable-nativeaot-plumbing

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Independent build-plumbing fixes for the trimmable type map on NativeAOT (the opt-in -p:_AndroidTypeMapImplementation=trimmable + _AndroidRuntime=NativeAOT path). These stand alone and do not depend on making the trimmable type map the NativeAOT default (#11822) — extracting them keeps that PR focused.

Fixes

  • ILC DGML collection (3 commits): fix XA4321 by collecting the ILC DGML from the per-RID inner build path; handle both single-RID output-path shapes; and fall back to the codegen DGML when the scan DGML is absent. These make the ACW/proguard keep-rule generation robust across the RID-nested inner-build layout.
  • Fix ManifestPlaceholders on the trimmable NativeAOT path — placeholder substitution in the generated manifest now works (ManifestGenerator).
  • _AndroidTrimmableTypemapTrimJavaCode toggle (default off) — optionally skip DGML parsing and keep all ACWs, trading a little dex size for skipping the very large DGML files that dominate NativeAOT build time. GenerateNativeAotProguardConfiguration no longer requires DGML input when disabled.
  • Generate per-process runtime provider Java sources — share provider-source generation between MonoVM and NativeAOT (GenerateAdditionalProviderSources / GenerateNativeAotBootstrapSources).
  • Keep user AndroidJavaSource under R8 — user-authored Java (no managed peer) is absent from the ACW map, so emit explicit -keep rules; otherwise R8 shrinks it away (which can, e.g., drop sources so an app that needs multidex no longer does).
  • Keep the _Microsoft.Android.Resource.Designer assembly — resource ids are resolved via reflection (ResourceIdManager), which the trimmer can't follow; root the designer via TrimmerRootAssembly (honored by both ILLink and ILC) so it isn't trimmed away (otherwise resource-id access throws FileNotFoundException: _Microsoft.Android.Resource.Designer).

Testing

  • Microsoft.Android.Sdk.TrimmableTypeMap generator + GenerateTrimmableTypeMapTests build clean; full validation via CI (device tests) since these exercise the on-device NativeAOT path.

Extracted from #11822.

simonrozsival and others added 8 commits July 5, 2026 22:23
…ID inner build path

_ResolveAssemblies always dispatches a per-RID inner build with
AppendRuntimeIdentifierToOutputPath=true, so ILC writes each *.scan.dgml.xml
under the RID-nested obj/<cfg>/<rid>/native/ path -- for both a single
explicit RuntimeIdentifier and a RuntimeIdentifiers list.

_CollectTrimmableNativeAotDgmlFiles runs in the outer build, whose
NativeIntermediateOutputPath has no RID segment. The single-RuntimeIdentifier
branch collected from that flat path (obj/<cfg>/native/), which never exists,
so _GenerateTrimmableTypeMapProguardConfiguration failed with XA4321 across
every single-RID NativeAOT build (e.g. IncrementalBuildDifferentDevice and the
other MockPrimaryCpuAbi tests).

Reconstruct the RID-nested path for both the single-RuntimeIdentifier and the
RuntimeIdentifiers cases (they now share one item expression); the flat
NativeIntermediateOutputPath remains only as the defensive no-RID fallback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
(cherry picked from commit 1512449)
…ath shapes)

The previous XA4321 fix assumed ILC's *.scan.dgml.xml always lives at the
RID-nested $(IntermediateOutputPath)<rid>/native/. That is only true when the
outer $(IntermediateOutputPath) has no RID segment (a $(RuntimeIdentifiers)
list, or a RID assigned late by _GetPrimaryCpuAbi). For a single explicit
$(RuntimeIdentifier) set early, the SDK already appended the RID to the outer
path, so reconstructing <rid>/native/ produced a doubled RID
(obj/Release/android-arm64/android-arm64/native/) and XA4321.

Emit both candidate paths ($(NativeIntermediateOutputPath) and the RID-nested
form), Exists()-filtered, and consume whichever ILC actually produced. This is
correct for single-RID (either output-path shape), multi-RID, and no-RID.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
(cherry picked from commit 6ea9395)
…L is absent

ILC only emits the scan graph (*.scan.dgml.xml) when its scanner phase runs
(optimized/Release builds). Unoptimized NativeAOT builds - e.g. a Debug
configuration, as produced when a solution is built without an explicit
Release configuration (AllProjectsHaveSameOutputDirectory) - emit only the
codegen graph (*.codegen.dgml.xml). The trimmable proguard-keep generator then
failed with XA4319 "No NativeAOT DGML files were provided".

The codegen graph carries the same "Type metadata: [...]" nodes the generator
reads, so collect it at the same candidate locations and use it only when no
scan graph was found (the scan graph is smaller, hence preferred).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
(cherry picked from commit e58cb04)
…T path

Two regressions surfaced by ManifestPlaceholders once the trimmable typemap
became the NativeAOT default:

* Placeholder values kept literal backslashes (e.g. "a=b\c"). The legacy
  pipeline re-encodes the substituted manifest through aapt2, which rewrites
  '\' to '/' on Unix; the trimmable generator writes the merged manifest
  directly, so normalize placeholder values to Path.DirectorySeparatorChar in
  ManifestGenerator.ApplyPlaceholders to keep the output identical.

* The legacy manifest merger has no _ManifestMerger step (that target only runs
  for manifestmerger.jar), so with AndroidManifestMerger=legacy nothing copied
  the already-merged trimmable manifest to obj/<cfg>/android/AndroidManifest.xml
  and _ReadAndroidManifest failed with a FileNotFoundException. Copy it into
  place on the trimmable path when the merger is not manifestmerger.jar.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
(cherry picked from commit 8af5b62)
…e toggle (default off)

The ILC DGML files used to drive Java Callable Wrapper trimming are very large
(hundreds of MB) and dominate NativeAOT build time. Until ILC/illink expose a
leaner typemap dump, gate that work behind a new property:

* _AndroidTrimmableTypemapTrimJavaCode (default false): skip DGML generation
  (IlcGenerateDgmlFile is no longer forced) and skip DGML collection, and have
  GenerateNativeAotProguardConfiguration emit a -keep rule for every ACW in the
  ACW map so R8 keeps them all. This costs a few hundred kB of extra dex but
  removes the huge DGML and its parsing/scan from every build.
* Set to true, the previous behavior is restored: ILC emits the DGML and the
  keep rules are computed from the DGML-retained subset.

GenerateNativeAotProguardConfiguration gains a TrimJavaCallableWrappers switch;
when false it no longer requires a DGML (NativeAotDgmlFiles is now optional).
Unit tests cover both the DGML-trimmed path and the keep-all path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
(cherry picked from commit f157df9)
…ava sources

A component with a non-default android:process (e.g. [BroadcastReceiver(Process=":remote")])
makes the manifest generator emit an extra runtime provider (NativeAotRuntimeProvider_1)
and return its name in AdditionalProviderSources. On the legacy/LLVM-IR path
GenerateAdditionalProviderSources writes the matching Java source, but the trimmable path
only surfaced the item and never generated the .java, so NativeAotRuntimeProvider_1 was
missing from classes.dex (Desugar failed on NativeAOT).

Extract the provider-source writing into a shared
GenerateAdditionalProviderSources.WriteAdditionalRuntimeProviderSources helper and call it
from GenerateNativeAotBootstrapSources (which already runs on the trimmable path), passing
@(_AdditionalProviderSources).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
(cherry picked from commit 934cd3b)
On the trimmable NativeAOT path R8 was given only a comment for
proguard_project_primary.cfg, so user-authored AndroidJavaSource types
(Bind != true) — which have no managed peer and are absent from the acw-map —
were tree-shaken out of classes.dex. Among other things this dropped large
unreferenced sources, so an app that required multidex no longer did and
classes2.dex was never produced (BuildAfterMultiDexIsNotRequired failed on
NativeAOT).

Emit the GetUserJavaTypes() -keep rules into proguard_project_primary.cfg on the
trimmable path too (the ACW keeps still come from proguard_project_references.cfg).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
(cherry picked from commit 8cfd0fd)
… path

Accessing a resource id under the trimmable type map (the NativeAOT default) threw at runtime:

  System.TypeInitializationException
    ---> System.IO.FileNotFoundException: _Microsoft.Android.Resource.Designer

Root cause: the _Microsoft.Android.Resource.Designer assembly IS added to the publish set
(_AddResourceDesignerToPublishFiles marks it PostprocessAssembly=true, so the SDK adds it as an
IlcReference), but it is left trimmable (IsTrimmable=true). Resource ids are resolved via
reflection at runtime (Android.Runtime.ResourceIdManager -> ResourceDesignerAttribute), which
the trimmer cannot follow, so ILC trims the whole designer assembly away and any reflection-only
resource-id access (e.g. an NUnit [TestCaseSource] static field) fails to load it.

The non-trimmable (LlvmIr) path avoids this because PreTrimmingFixLegacyDesigner rewrites legacy
field loads and, more importantly, it roots all publish assemblies for ILC. That rewrite is a
no-op for modern assemblies (whose Resource already derives from the designer), so it is not the
right fix here.

Instead, simply keep the designer assembly: add it to TrimmerRootAssembly on the trimmable path.
TrimmerRootAssembly is honored by both ILLink (CoreCLR) and ILC (NativeAOT, emitted as --root:);
ILC's TrimMode-based rooting can't be used because _AndroidComputeIlcCompileInputs clears
@(_IlcManagedInputAssemblies). No IL rewriting is required.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
(cherry picked from commit 449e2a1)
Copilot AI review requested due to automatic review settings July 5, 2026 20:41

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

This PR tightens up build plumbing for the trimmable typemap + NativeAOT path, focusing on robustness and (optional) performance tradeoffs: improved DGML discovery/collection, fixing manifest placeholder behavior, generating per-process runtime provider Java sources, and ensuring shrinking keeps user-authored Java and the resource designer assembly.

Changes:

  • Add a TrimJavaCallableWrappers toggle to allow skipping DGML parsing and keeping all JCWs when desired.
  • Improve NativeAOT DGML file collection (inner-build/RID-nested outputs + scan/codegen fallback) and wire it into Proguard config generation.
  • Ensure generated manifests/placeholders and runtime-provider Java sources behave consistently across trimmable/legacy build paths; root resource designer assembly for trimming.
Show a summary per file
File Description
src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs Adds a regression test ensuring Proguard config generation works without DGML when trimming is disabled.
src/Xamarin.Android.Build.Tasks/Tasks/R8.cs Emits explicit R8 -keep rules for user-authored AndroidJavaSource types on the trimmable NativeAOT path.
src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeAotProguardConfiguration.cs Introduces TrimJavaCallableWrappers to optionally bypass DGML and keep all ACWs from the map.
src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeAotBootstrapSources.cs Adds support for generating additional per-process runtime provider Java sources on the trimmable path.
src/Xamarin.Android.Build.Tasks/Tasks/GenerateAdditionalProviderSources.cs Refactors additional runtime-provider source emission into a shared helper usable by both legacy and trimmable flows.
src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets Wires additional provider sources into NativeAOT bootstrap generation; ensures the merged manifest lands in the expected intermediate location; roots the resource designer assembly.
src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets Adds the _AndroidTrimmableTypemapTrimJavaCode toggle, gates DGML generation/collection on it, and adds scan/codegen DGML fallback logic.
src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestGenerator.cs Normalizes backslashes in manifest placeholder values to match legacy output behavior.

Copilot's findings

  • Files reviewed: 8/8 changed files
  • Comments generated: 3

Comment on lines +20 to 24
<!-- Only ask ILC for the DGML when JCW trimming is enabled; it is only consumed to compute the keep rules. -->
<IlcGenerateDgmlFile Condition=" '$(_AndroidTrimmableTypemapTrimJavaCode)' == 'true' and '$(AndroidLinkTool)' != '' and '$(IlcGenerateDgmlFile)' == '' ">true</IlcGenerateDgmlFile>
<_UseTrimmableNativeAotProguardConfiguration Condition=" '$(_UseTrimmableNativeAotProguardConfiguration)' == '' ">true</_UseTrimmableNativeAotProguardConfiguration>
<_CompileToDalvikDependsOnTargets>$(_CompileToDalvikDependsOnTargets);_GenerateTrimmableTypeMapProguardConfiguration</_CompileToDalvikDependsOnTargets>
</PropertyGroup>
Comment on lines 118 to 122
<GenerateNativeAotProguardConfiguration
NativeAotDgmlFiles="@(_TrimmableNativeAotDgmlFiles)"
AcwMapFile="$(IntermediateOutputPath)acw-map.txt"
TrimJavaCallableWrappers="$(_AndroidTrimmableTypemapTrimJavaCode)"
OutputFile="$(_ProguardProjectConfiguration)" />
Condition=" '$(AndroidUseDesignerAssembly)' == 'True' and '$(PublishTrimmed)' == 'true' "
BeforeTargets="PrepareForILLink;WriteIlcRspFileForCompilation">
<ItemGroup>
<TrimmerRootAssembly Include="_Microsoft.Android.Resource.Designer" />
ILC only loads assemblies passed to it as references; _Microsoft.Android.Resource.Designer is a
compile-time reference but not in @(IlcReference), so rooting it via TrimmerRootAssembly alone
fails: ilc error 'Failed to load assembly _Microsoft.Android.Resource.Designer'. Add it to
@(IlcReference) too, driven off @(ReferencePath) filtered to the designer so nothing is
referenced/rooted when the designer assembly was not generated. @(IlcReference) is ignored by
ILLink, so the CoreCLR trimmable path only gets the root.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants