Skip to content

Commit c440654

Browse files
[Xamarin.Android.Build.Tasks] mostly R2R the main assembly (#12248)
crossgen2's `--partial` only precompiles methods named in the profile data passed via `--mibc`. There is no profile for a new app, so today the main app assembly is effectively not ReadyToRun compiled at all. This adds a `GenerateMibcProfile` task that writes a `.mibc` naming the compilable methods of the main app assembly and adds it to `@(PublishReadyToRunPgoFiles)`, so crossgen2 compiles that assembly while everything else stays partial. Android version of dotnet/maui#34837. The target runs `AfterTargets="ILLink" BeforeTargets="CreateReadyToRunImages"`, so the profile only contains methods that survived trimming, and only when `$(PublishReadyToRunCrossgen2ExtraArgs)` contains `--partial`. Two things worth knowing: - Methods in a generic context are left to the JIT. crossgen2 compiles those as shared code over `System.__Canon`, which a profile can only name via that implementation detail. So the assembly ends up mostly, not fully, R2R compiled. - Output is deterministic, so an unchanged app produces a byte identical `.mibc` and does not invalidate incremental builds. ## Bring your own profile If you record a real startup profile, for example with the `maui profile startup --format mibc` skill in [dotnet/maui-labs](https://github.com/dotnet/maui-labs), you have already said exactly what you want compiled, and the whole point of `--partial` is to compile only that. Naming every method of the app assembly on top of it would swamp it. So this turns itself off when `@(PublishReadyToRunPgoFiles)` is already non-empty. Just adding your `.mibc` to that item group is enough, no extra property needed. MAUI's own framework profiles go into the private `@(_ReadyToRunPgoFiles)`, so they do not suppress this. `$(_AndroidReadyToRunMainAssembly)` overrides the decision: `true` forces the profile on, `false` turns it off, blank (the default) means "on when crossgen2 is partial and the app brought no profile of its own". ## Results Measured on the two MAUI templates, Android-only TFM, CoreCLR, Release, composite, `--partial;--map`, `-p:RuntimeIdentifier=android-arm64` so the APK carries a single ABI and the size numbers are not diluted by a second copy of everything. Each pair is the same project built twice, differing only by `$(_AndroidReadyToRunMainAssembly)`, so the profile is the only variable. Startup is from a physical arm64 device: 25 iterations per run, and the whole thing run twice in opposite order (before/after, then after/before) so any ordering or thermal drift would show up as a gap between the two passes of the same APK. It does not. | | `dotnet new maui` | `dotnet new maui -sc` | | --- | --- | --- | | methods in profile | 87 | 1,291 | | profile size | 1,829 bytes | 14,867 bytes | | skipped as generic | 0 | 23 | | app methods in R2R image | 0 -> 87 | 0 -> 1,289 | | APK size | 20,013,784 -> 20,079,320 | 23,032,560 -> 23,298,800 | | APK delta | +65,536 (+0.33%) | +266,240 (+1.16%) | | startup before | 826.6 +/- 3.6, 825.2 +/- 2.9 ms | 1685.1 +/- 3.8, 1688.3 +/- 4.0 ms | | startup after | 805.7 +/- 2.7, 806.6 +/- 2.7 ms | 1609.0 +/- 2.9, 1613.7 +/- 3.7 ms | | startup delta | -19.8 ms (-2.4%) | -75.4 ms (-4.5%) | Both passes of each APK agree to within a couple of ms, and the before/after gap is 5-15x the combined standard error, so the deltas are well clear of the noise. Essentially every method named in the profile lands in the R2R image, and the trade scales with how much code the app actually has: the blank template gains 87 methods for 64 KB, `-sc` gains 1,289 for 260 KB. `apkdiff` shows all the growth is the composite R2R image; dex and every other entry are byte identical in both templates. ``` $ apkdiff full-arm64-before.apk full-arm64-after.apk # dotnet new maui + 66,488 lib/arm64-v8a/libassembly-store.so Summary: + 0 Other entries 0.00% (of 3,930,902) + 0 Dalvik executables 0.00% (of 16,997,756) + 66,488 Shared libraries 0.39% (of 17,016,032) + 65,536 Package size difference 0.33% (of 20,013,784) $ apkdiff sc-arm64-before.apk sc-arm64-after.apk # dotnet new maui -sc + 267,632 lib/arm64-v8a/libassembly-store.so Summary: + 0 Other entries 0.00% (of 7,041,375) + 0 Dalvik executables 0.00% (of 17,026,184) + 267,632 Shared libraries 1.34% (of 20,013,592) + 266,240 Package size difference 1.16% (of 23,032,560) ``` ## Build time cost This never runs in a Debug build. `$(PublishReadyToRun)` only defaults to `true` for `$(Configuration)` of `Release`, and the target additionally requires crossgen2 to be in partial mode, so the inner-loop build is untouched. It is also CoreCLR only, since `Microsoft.Android.Sdk.CoreCLR.targets` is imported only for that runtime. For Release builds, `GenerateMibcProfile` runs once per RID. Durations pulled from the binlogs: | build | methods | 1st RID | 2nd RID | | --- | --- | --- | --- | | `dotnet new maui` | 87 | 33.6 ms | 1.0 ms | | `dotnet new maui -sc` | 1,291 | 34.5 ms | 1.9 ms | | `-sc`, `PublishTrimmed=false` | 1,291 | 40.4 ms | 1.9 ms | So 35-45 ms per build, against roughly 50 s of wall clock and 128-142 s of total task time. Whichever RID runs first pays about 34 ms of task assembly load and JIT; the second pays 1-2 ms, and that marginal cost is the same whether the profile names 87 methods or 1,291. Writing the profile is effectively free, you are just paying to load the task once. For scale, `RunReadyToRunCompiler` is 4.0-5.0 s in these same builds, so this is well under 1% of crossgen2's own time. ## Tests `GenerateMibcProfileTests` for the writer, and `InstallAndRunTests.PublishReadyToRunPartial ([Values] bool isComposite)` builds, verifies the app assembly is R2R compiled in the APK, then installs and launches it. Both variants pass on an x86_64 emulator. All four decision paths were verified end to end on a real MAUI app: partial with no profile generates it, a user supplied `@(PublishReadyToRunPgoFiles)` suppresses it, and each explicit value of `$(_AndroidReadyToRunMainAssembly)` overrides both. Both trimming paths were checked too: the default build profiles `obj/.../linked/App.dll`, and `-p:PublishTrimmed=false` profiles `obj/.../App.dll`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent fb30bbf commit c440654

7 files changed

Lines changed: 1063 additions & 13 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
---
2+
applyTo: "**/*.targets,**/*.props,**/*.proj,**/*.csproj"
3+
---
4+
5+
# MSBuild conventions
6+
7+
Rules for authoring MSBuild targets, wherever they live — `.targets`, `.props`, `.proj`, and the
8+
18 `.csproj` files in this repo that declare their own `<Target>`. They matter most for the product
9+
targets under `src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/` and
10+
`src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets`, which ship to customers.
11+
12+
## Every target that produces a file must be incremental
13+
14+
Any target invoking a task that writes a file needs `Inputs` and `Outputs`. A target without them
15+
re-runs on every build, which shows up directly in customer inner-loop build times.
16+
17+
```xml
18+
<Target Name="_AndroidGenerateSomething"
19+
AfterTargets="_SomeUpstreamTarget"
20+
DependsOnTargets="_AndroidGenerateSomethingInputs"
21+
Inputs="@(_AndroidSomethingInput)"
22+
Outputs="$(_AndroidSomethingOutput)">
23+
<GenerateSomething Input="@(_AndroidSomethingInput)" OutputFile="$(_AndroidSomethingOutput)" />
24+
</Target>
25+
```
26+
27+
### `Outputs` should be the real output file
28+
29+
Write the output file directly from the task and use that file as `Outputs`. Do **not** reach for a
30+
temp file + `Files.CopyIfChanged` + a stamp file unless there is a concrete reason the timestamp
31+
must be preserved (e.g. the file feeds another incremental target that would otherwise cascade).
32+
33+
`CopyIfChanged` deliberately leaves the timestamp alone when content is unchanged, which means the
34+
real output can never serve as `Outputs` — you are then forced to invent a
35+
`$(...)Stamp` file, `<MakeDir/>` it, `<Touch/>` it, and add it to `@(FileWrites)`. That is a lot of
36+
machinery to buy back something you did not need. Prefer the simple version.
37+
38+
## Compute `Inputs` in a `<TargetName>Inputs` target
39+
40+
Do not compute the input `ItemGroup` inside the target that consumes it — `Inputs` is evaluated
41+
before the target body runs, so the item group would be empty on the first evaluation. Put it in a
42+
separate target pulled in via `DependsOnTargets`, named `<TargetName>Inputs`:
43+
44+
```xml
45+
<Target Name="_AndroidGenerateSomethingInputs">
46+
<ItemGroup>
47+
<_AndroidSomethingInput Include="@(_SomeBigList)" Condition=" '%(Filename)' == '$(TargetName)' " />
48+
</ItemGroup>
49+
</Target>
50+
```
51+
52+
`DependsOnTargets` targets run **before** the parent target's `Condition`, `Inputs`, and `Outputs`
53+
are evaluated, so this works.
54+
55+
### Output paths belong in the same target
56+
57+
`$(TargetName)`, `$(TargetFileName)`, `$(IntermediateOutputPath)`, `$(OutDir)` and friends are
58+
**not final** when our `.targets` files are evaluated — `$(TargetName)` is often blank, and
59+
`$(IntermediateOutputPath)` has not yet had the RID/TFM subdirectory appended. A top-level
60+
`<PropertyGroup>` computing an output path from them silently produces garbage such as
61+
`obj\Release\.mibc` instead of `obj\Release\net11.0\android-arm64\MyApp.mibc`.
62+
63+
Compute output paths in a `<PropertyGroup>` inside the `<TargetName>Inputs` target alongside the
64+
input items. `DependsOnTargets` guarantees it runs before the parent's `Outputs` is evaluated.
65+
66+
```xml
67+
<Target Name="_AndroidGenerateSomethingInputs">
68+
<PropertyGroup>
69+
<_AndroidSomethingOutput>$(IntermediateOutputPath)$(TargetName).ext</_AndroidSomethingOutput>
70+
</PropertyGroup>
71+
<ItemGroup>
72+
...
73+
</ItemGroup>
74+
</Target>
75+
```
76+
77+
### `Inputs` must be the actual inputs, not a superset
78+
79+
Never list a whole upstream item group as `Inputs` when the task only consumes one item from it.
80+
Doing so makes the target re-run whenever *any* unrelated file in that list changes. Filter first,
81+
then use the filtered item group as `Inputs`.
82+
83+
Filter in MSBuild — using `%(Filename)` batching and `@(X->Distinct())` — rather than
84+
passing everything to the task and filtering in C#. Prefer the simplest well-known property for the
85+
comparison (`%(Filename)` vs `$(TargetName)`, not `%(Filename)%(Extension)` vs `$(TargetFileName)`).
86+
Keep the task's surface area minimal; a task that takes `[Required] string MainAssembly` is easier
87+
to reason about and unit test than one that takes an `ITaskItem[]` plus a filter property.
88+
89+
## Item groups in a skipped target *are* still evaluated
90+
91+
If a target is skipped as **up to date** (its `Inputs` are older than its `Outputs`), MSBuild still
92+
evaluates the `<ItemGroup>` and `<PropertyGroup>` elements inside it. Downstream targets see those
93+
items. So do **not** split item population into a second `AfterTargets` target "so it still runs on
94+
incremental builds" — that is unnecessary indirection.
95+
96+
```xml
97+
<Target Name="_AndroidGenerateSomething" Inputs="..." Outputs="...">
98+
<GenerateSomething ... />
99+
<!-- Still evaluated when the target is skipped as up to date. -->
100+
<ItemGroup Condition=" Exists('$(_AndroidSomethingOutput)') ">
101+
<_SomeConsumerList Include="$(_AndroidSomethingOutput)" />
102+
<FileWrites Include="$(_AndroidSomethingOutput)" />
103+
</ItemGroup>
104+
</Target>
105+
```
106+
107+
This is **not** true for a target skipped by `Condition="false"` — that skips the entire target
108+
body, item groups included.
109+
110+
## Read late-set properties from a target `Condition`, never at evaluation time
111+
112+
Properties set by NuGet `buildTransitive` `.targets` (from packages like `Microsoft.Maui.Controls`)
113+
are assigned **after** our `.targets` are evaluated. A top-level `<PropertyGroup>` that reads such a
114+
property will see it blank.
115+
116+
```xml
117+
<!-- WRONG: $(PublishReadyToRunCrossgen2ExtraArgs) may not be set yet. -->
118+
<PropertyGroup>
119+
<_AndroidDoThing Condition=" $(PublishReadyToRunCrossgen2ExtraArgs.Contains('...')) ">true</_AndroidDoThing>
120+
</PropertyGroup>
121+
122+
<!-- RIGHT: evaluated when the target runs, after everything is assigned. -->
123+
<Target Name="_AndroidDoThing" Condition=" $(PublishReadyToRunCrossgen2ExtraArgs.Contains('...')) ">
124+
```
125+
126+
A helper property that merely aliases a condition is usually not worth it — inline the check in the
127+
target's `Condition`.
128+
129+
## A target's `Condition` is evaluated *before* its `DependsOnTargets`
130+
131+
So a `Condition` can never read a property that one of its dependencies sets — the dependency is
132+
never built. This is tempting when a decision has several inputs and you want to resolve it into a
133+
single property in the `Inputs` helper target; it silently never runs.
134+
135+
Giving the helper the same `AfterTargets`/`BeforeTargets` hooks does work (targets sharing a hook
136+
run in declaration order), but it makes correctness depend on declaration order. Prefer keeping the
137+
whole decision inline in the `Condition`, even when it needs nested parentheses.
138+
139+
## Test item emptiness with `->Count()`
140+
141+
`'@(SomeItem)' == ''` batches the whole list into a string just to see whether it is empty. Use
142+
`'@(SomeItem->Count())' == '0'` instead.
143+
144+
## Don't invent public properties
145+
146+
Prefer keying off properties that already exist. Do not add a public `$(AndroidFooBar)` opt-in/
147+
opt-out knob unless a customer genuinely needs it — every public property is documentation,
148+
localization, and a compatibility commitment.
149+
150+
When an internal escape hatch is needed (typically so a test can force a codepath), add a
151+
**private** `$(_Android*)` property that is **blank by default** — no `<PropertyGroup>` default for
152+
it anywhere — and `or` it into the existing condition:
153+
154+
```xml
155+
Condition=" '$(PublishReadyToRun)' == 'true' and '$(_AndroidReadyToRunMainAssembly)' != 'false' and ('$(_AndroidReadyToRunMainAssembly)' == 'true' or ($(PublishReadyToRunCrossgen2ExtraArgs.Contains('--partial')) and '@(PublishReadyToRunPgoFiles->Count())' == '0')) "
156+
```
157+
158+
Give the hatch three states rather than two: `true` forces the codepath on, `false` forces it off,
159+
and blank means "decide automatically". A force-on-only hatch leaves no way to build the baseline
160+
it is meant to be compared against.
161+
162+
Public properties go in `Documentation/docs-mobile/building-apps/build-properties.md`; private
163+
`_`-prefixed ones must not.
164+
165+
## Naming
166+
167+
* Private targets, properties, and items are `_Android`-prefixed: `_AndroidGenerateMibcProfile`,
168+
`$(_AndroidMibcProfile)`, `@(_AndroidMibcMainAssembly)`.
169+
* An `Inputs`-computing helper target is `<TargetName>Inputs`, not `_AndroidPrepareXxx` or similar.
170+
* Names should say what the thing *is*, not how it is used.
171+
172+
## `--` is illegal inside an XML comment
173+
174+
Writing `--partial`, `--map`, or any `--` sequence inside an `<!-- ... -->` block produces an XML
175+
parse error. Escape it, reword it, or move it out of the comment. Always validate after editing:
176+
177+
```powershell
178+
try { [xml]$x = Get-Content path\to\File.targets; "ok" } catch { "INVALID: $($_.Exception.Message)" }
179+
```
180+
181+
## Other verified semantics
182+
183+
* If `Inputs` evaluates to empty and `Outputs` is non-empty, MSBuild skips the target for having no
184+
inputs. Guard accordingly if an empty input list is legitimate.
185+
* `$(UndefinedProperty.Contains('x'))` safely evaluates to `false`; no null check is needed.
186+
* `@(X->Distinct())` is a valid item function and is the right way to dedupe overlapping item lists.
187+
* Add generated files to `@(FileWrites)` so `Clean` removes them.
188+
* Use `TaskFactory="TaskHostFactory"` and `Runtime="NET"` on `<UsingTask/>` for **internal**
189+
build-time tasks (`xa-prep-tasks`, `BootstrapTasks`) only — never on tasks shipped to customers.

src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.CoreCLR.targets

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ This file contains the CoreCLR-specific MSBuild logic for .NET for Android.
77
-->
88
<Project>
99

10+
<UsingTask TaskName="Xamarin.Android.Tasks.GenerateMibcProfile" AssemblyFile="$(_XamarinAndroidBuildTasksAssembly)" />
11+
1012
<!-- Default property values for CoreCLR -->
1113
<PropertyGroup>
1214
<_AndroidRuntimePackRuntime>CoreCLR</_AndroidRuntimePackRuntime>
@@ -49,4 +51,69 @@ This file contains the CoreCLR-specific MSBuild logic for .NET for Android.
4951
Condition=" '%(ResolvedRuntimePack.FrameworkName)' == 'Microsoft.NETCore.App' And Exists('$(_DotnetRuntimeRepo)/artifacts/bin/microsoft.netcore.app.runtime.$(RuntimeIdentifier)/$(_DotNetRuntimeConfiguration)') " />
5052
</ItemGroup>
5153
</Target>
54+
55+
<!--
56+
The crossgen2 "partial" switch restricts ReadyToRun compilation to the methods found in the
57+
profile data passed via "mibc". That is a good trade for framework assemblies, but it means
58+
none of the application's own code gets precompiled unless it appears in a profile.
59+
60+
Generate a MIBC profile naming the methods of the main app assembly so that it is
61+
ReadyToRun compiled, while the rest of the app remains partially compiled.
62+
63+
Everything below deliberately sticks to public MSBuild names. `ILLink` and
64+
`CreateReadyToRunImages` bracket the window where the trimmed assembly exists but crossgen2
65+
has not run yet, and `@(PublishReadyToRunPgoFiles)` is the supported way to hand a profile to
66+
crossgen2; `_PrepareForReadyToRunCompilation` copies it into its own private item group.
67+
68+
`$(_AndroidReadyToRunMainAssembly)` decides whether the profile is generated, in order:
69+
70+
1. An explicit `true` or `false` always wins.
71+
2. Otherwise off when the app supplies its own `@(PublishReadyToRunPgoFiles)`.
72+
3. Otherwise on when crossgen2 is running in partial mode.
73+
74+
An app that supplies its own profile, e.g. one recorded from a real startup trace with
75+
`dotnet-pgo`, has already said exactly what it wants compiled, and the whole point of partial
76+
mode is to compile only that. Naming every method of the app assembly on top of it would
77+
swamp that profile, so leave those builds alone. Note this deliberately keys off the public
78+
item: MAUI's own framework profiles go into `@(_ReadyToRunPgoFiles)`, so they do not
79+
suppress this.
80+
-->
81+
<!-- $(TargetName) and $(IntermediateOutputPath) are not final until well after this file is
82+
evaluated, so both the profile path and the main assembly are computed inside a target. -->
83+
<Target Name="_AndroidGenerateMibcProfileInputs">
84+
<PropertyGroup>
85+
<_AndroidMibcProfile>$(IntermediateOutputPath)$(TargetName).mibc</_AndroidMibcProfile>
86+
<!--
87+
The main app assembly exactly as crossgen2 will see it. ILLink writes the trimmed
88+
assembly to $(IntermediateLinkDir), and this runs after it. Without trimming
89+
($(AndroidLinkMode) of `None`, an explicit $(PublishTrimmed) of `false`, or
90+
$(RunILLink) of `false`) ILLink never runs and crossgen2 compiles the compiler output
91+
instead. This mirrors when ILLink actually runs rather than probing for the trimmed
92+
file, so a stale `linked` directory from an earlier build cannot be picked up.
93+
-->
94+
<_AndroidMibcMainAssembly Condition=" '$(PublishTrimmed)' == 'true' and '$(RunILLink)' != 'false' ">$(IntermediateLinkDir)$(TargetName)$(TargetExt)</_AndroidMibcMainAssembly>
95+
<_AndroidMibcMainAssembly Condition=" '$(_AndroidMibcMainAssembly)' == '' ">@(IntermediateAssembly->'%(FullPath)')</_AndroidMibcMainAssembly>
96+
</PropertyGroup>
97+
</Target>
98+
99+
<!-- The decision is inline rather than resolved into a property by the target above, because
100+
MSBuild evaluates a target's Condition *before* building its DependsOnTargets, so a
101+
Condition can never read a property one of them sets. -->
102+
<Target Name="_AndroidGenerateMibcProfile"
103+
AfterTargets="ILLink"
104+
BeforeTargets="CreateReadyToRunImages"
105+
DependsOnTargets="_AndroidGenerateMibcProfileInputs"
106+
Condition=" '$(PublishReadyToRun)' == 'true' and '$(_AndroidReadyToRunMainAssembly)' != 'false' and ('$(_AndroidReadyToRunMainAssembly)' == 'true' or ($(PublishReadyToRunCrossgen2ExtraArgs.Contains('--partial')) and '@(PublishReadyToRunPgoFiles->Count())' == '0')) "
107+
Inputs="$(_AndroidMibcMainAssembly)"
108+
Outputs="$(_AndroidMibcProfile)">
109+
<GenerateMibcProfile
110+
MainAssembly="$(_AndroidMibcMainAssembly)"
111+
OutputFile="$(_AndroidMibcProfile)" />
112+
<!-- MSBuild still evaluates ItemGroup/PropertyGroup elements of a target skipped as up to
113+
date, so @(PublishReadyToRunPgoFiles) is populated on incremental builds too. -->
114+
<ItemGroup Condition=" Exists('$(_AndroidMibcProfile)') ">
115+
<PublishReadyToRunPgoFiles Include="$(_AndroidMibcProfile)" />
116+
<FileWrites Include="$(_AndroidMibcProfile)" />
117+
</ItemGroup>
118+
</Target>
52119
</Project>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#nullable enable
2+
3+
using System.IO;
4+
using Microsoft.Android.Build.Tasks;
5+
using Microsoft.Build.Framework;
6+
7+
namespace Xamarin.Android.Tasks;
8+
9+
/// <summary>
10+
/// Generates a MIBC profile listing the methods of the "main app assembly" so that
11+
/// <c>crossgen2</c> in partial mode still ReadyToRun compiles it.
12+
///
13+
/// This runs after ILLink has trimmed the application, but before crossgen2 runs, so the profile
14+
/// only ever contains methods that survived trimming.
15+
/// </summary>
16+
public class GenerateMibcProfile : AndroidTask
17+
{
18+
public override string TaskPrefix => "GMP";
19+
20+
/// <summary>
21+
/// The "main app assembly", after trimming.
22+
/// </summary>
23+
[Required]
24+
public string MainAssembly { get; set; } = "";
25+
26+
/// <summary>Path of the <c>.mibc</c> file to write.</summary>
27+
[Required]
28+
public string OutputFile { get; set; } = "";
29+
30+
public override bool RunTask ()
31+
{
32+
if (!File.Exists (MainAssembly)) {
33+
Log.LogDebugMessage ($"Skipping MIBC profile generation, '{MainAssembly}' does not exist.");
34+
return !Log.HasLoggedErrors;
35+
}
36+
37+
int methods = MibcProfileWriter.Write ([MainAssembly], OutputFile, message => Log.LogDebugMessage ("{0}", message));
38+
Log.LogDebugMessage ($"Wrote {methods} method(s) to '{OutputFile}'.");
39+
40+
return !Log.HasLoggedErrors;
41+
}
42+
}

0 commit comments

Comments
 (0)