Skip to content

[TrimmableTypeMap] Add GenerateTrimmableTypeMap MSBuild task and targets#10924

Draft
simonrozsival wants to merge 13 commits intomainfrom
dev/simonrozsival/trimmable-typemap-05-msbuild-task
Draft

[TrimmableTypeMap] Add GenerateTrimmableTypeMap MSBuild task and targets#10924
simonrozsival wants to merge 13 commits intomainfrom
dev/simonrozsival/trimmable-typemap-05-msbuild-task

Conversation

@simonrozsival
Copy link
Member

Part of #10800
Stacked on #10917.

Summary

Adds the GenerateTrimmableTypeMap MSBuild task and wires it into the trimmable targets files, replacing the stub _GenerateJavaStubs target with real typemap + JCW generation.

Task (GenerateTrimmableTypeMap)

  • Extends AndroidTask (TaskPrefix GTT)
  • Scans resolved assemblies → groups by source assembly → generates per-assembly typemap .dll assemblies → generates root _Microsoft.Android.TypeMaps.dll → generates JCW .java source files
  • Filters BCL assemblies (FrameworkAssembly=true without HasMonoAndroidReference) to skip unnecessary scanning
  • Per-assembly timestamp check: skips emission when output .TypeMap.dll is newer than source .dll

Targets

  • Microsoft.Android.Sdk.TypeMap.Trimmable.targets: Replaces stub with real GenerateTrimmableTypeMap task call. Defines _TypeMapOutputDirectory and _TypeMapJavaOutputDirectory (co-located under typemap/). Configures RuntimeHostConfigurationOption for TypeMappingEntryAssembly.
  • Trimmable.CoreCLR.targets: Adds generated assemblies to _ResolvedAssemblies for the linker (without TrimmerRootAssembly — the trimmer must process TypeMapAttribute entries and trim ones whose target types were removed).
  • Trimmable.NativeAOT.targets: Adds to IlcReference + UnmanagedEntryPointsAssembly (without TrimmerRootAssembly).

Performance

Benchmark on MacBook M1, scanning Mono.Android.dll (~8870 types), 169 iterations over 5 minutes:

Phase Avg P50 P95 Min
Scan (8870 types) 235ms 224ms 319ms 175ms
Emit (typemap assemblies) 86ms 75ms 181ms 51ms
JCW (315 Java files) 766ms 409ms 3167ms 102ms
Total cold 1088ms 702ms 3878ms 370ms
Total incremental (scan only) 221ms 215ms 282ms 179ms
Savings 867ms (80%)

Cold build cost is dominated by JCW file I/O (high p95 variance from disk contention). Incremental builds (unchanged assemblies) skip emit entirely — only the scan runs (~215ms).

Note: scanning currently rescans all assemblies on every build (needed for cross-assembly type resolution). This is a known limitation — future optimization could cache scan results or add a two-list scan overload that only produces JavaPeerInfo for changed assemblies while still indexing all for cross-references. Profiling is needed before optimizing.

Benchmark script
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Microsoft.Android.Sdk.TrimmableTypeMap;

var monoAndroidDll = args[0];
int durationSeconds = args.Length > 1 ? int.Parse(args[1]) : 300;

// Warmup (3 runs)
for (int w = 0; w < 3; w++) {
    var d = Path.Combine(Path.GetTempPath(), $"typemap-warmup-{w}");
    Directory.CreateDirectory(d);
    var j = Path.Combine(d, "java");
    Directory.CreateDirectory(j);
    using (var s = new JavaPeerScanner()) {
        var p = s.Scan(new[] { monoAndroidDll });
        new TypeMapAssemblyGenerator(new Version(11, 0)).Generate(p, Path.Combine(d, "w.dll"), "w");
        new JcwJavaSourceGenerator().Generate(p, j);
    }
    Directory.Delete(d, true);
}

var scanT = new List<long>();
var emitT = new List<long>();
var jcwT = new List<long>();
var totalColdT = new List<long>();
var scanOnlyT = new List<long>();
var deadline = Stopwatch.StartNew();

while (deadline.Elapsed.TotalSeconds < durationSeconds) {
    var outputDir = Path.Combine(Path.GetTempPath(), $"typemap-bench-{Guid.NewGuid():N}");
    Directory.CreateDirectory(outputDir);
    var javaDir = Path.Combine(outputDir, "java");
    Directory.CreateDirectory(javaDir);
    try {
        var sw = Stopwatch.StartNew();
        List<JavaPeerInfo> peers;
        using (var scanner = new JavaPeerScanner()) { peers = scanner.Scan(new[] { monoAndroidDll }); }
        sw.Stop(); scanT.Add(sw.ElapsedMilliseconds);

        sw = Stopwatch.StartNew();
        new TypeMapAssemblyGenerator(new Version(11, 0)).Generate(peers, Path.Combine(outputDir, "_M.dll"), "_M");
        new RootTypeMapAssemblyGenerator(new Version(11, 0)).Generate(new[] { "_M" }, Path.Combine(outputDir, "_R.dll"));
        sw.Stop(); emitT.Add(sw.ElapsedMilliseconds);

        sw = Stopwatch.StartNew();
        new JcwJavaSourceGenerator().Generate(peers, javaDir);
        sw.Stop(); jcwT.Add(sw.ElapsedMilliseconds);

        totalColdT.Add(scanT[^1] + emitT[^1] + jcwT[^1]);

        sw = Stopwatch.StartNew();
        using (var scanner = new JavaPeerScanner()) { scanner.Scan(new[] { monoAndroidDll }); }
        sw.Stop(); scanOnlyT.Add(sw.ElapsedMilliseconds);
    } finally {
        try { Directory.Delete(outputDir, true); } catch {}
    }
}

Tests

Unit tests (8 tests with MockBuildEngine):

  • Empty assembly list succeeds
  • Real Mono.Android.dll produces per-assembly + root typemaps
  • Second run skips up-to-date assemblies (timestamp unchanged, "up to date" logged)
  • Source touched → regenerates only changed assembly
  • Invalid TargetFrameworkVersion fails with error
  • TFV parsing (v11.0, v10.0, 11.0)

Integration tests (full dotnet build):

  • Build with _AndroidTypeMapImplementation=trimmable on CoreCLR succeeds
  • Incremental build skips _GenerateJavaStubs

Follow-up work

@simonrozsival simonrozsival force-pushed the dev/simonrozsival/trimmable-typemap-04-jcw-generator branch from d28814e to 04e0b2e Compare March 12, 2026 15:04
@simonrozsival simonrozsival force-pushed the dev/simonrozsival/trimmable-typemap-05-msbuild-task branch from 2d666a1 to 5bf8492 Compare March 12, 2026 15:07
Copy link
Member Author

@simonrozsival simonrozsival left a comment

Choose a reason for hiding this comment

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

🤖 AI Review Summary

Verdict: ✅ LGTM

Found 0 issues.

Reviewed against all 14 rule categories:

  • MSBuild tasks: Extends AndroidTask, TaskPrefix defined, returns !Log.HasLoggedErrors, [Required] properties have defaults, [Output] properties nullable
  • MSBuild targets: Internal names prefixed with _, Inputs/Outputs with stamp file for incrementality, AfterTargets used appropriately (no DependsOn available), no TrimmerRootAssembly (correct — trimmer must process TypeMapAttributes)
  • Nullable: No null-forgiving !, no #nullable enable (project-level)
  • Error handling: ParseTargetFrameworkVersion throws on bad input, no empty catch blocks
  • Performance: BCL assemblies filtered via FrameworkAssembly/HasMonoAndroidReference metadata, per-assembly timestamp check skips unchanged typemaps, LINQ used cleanly
  • Code organization: Types made public for cross-assembly consumption (build-time only, not shipped in apps), UsingTask scoped to trimmable targets only
  • Formatting: Tabs, Mono style throughout

👍 Clean task design with good incrementality (per-assembly timestamp skipping). Benchmark data in the PR description validates the approach. RuntimeHostConfigurationOption correctly shared between CoreCLR and NativeAOT. AfterTargets usage is justified since there's no DependsOn property to hook into.


Review generated by android-reviewer from review guidelines.

Base automatically changed from dev/simonrozsival/trimmable-typemap-04-jcw-generator to main March 12, 2026 21:54
simonrozsival and others added 13 commits March 13, 2026 07:41
Add the MSBuild task that wires the TrimmableTypeMap scanner and generators
into the build pipeline, replacing the stub _GenerateJavaStubs target.

### Task (GenerateTrimmableTypeMap)
- Extends AndroidTask, TaskPrefix 'GTT'
- Scans resolved assemblies for Java peer types
- Generates per-assembly TypeMap .dll assemblies
- Generates root _Microsoft.Android.TypeMaps.dll
- Generates JCW .java source files for ACW types

### Targets
- Microsoft.Android.Sdk.TypeMap.Trimmable.targets: replaces stub with
  real GenerateTrimmableTypeMap task call
- CoreCLR.targets: adds generated assemblies as TrimmerRootAssembly,
  configures RuntimeHostConfigurationOption for TypeMappingEntryAssembly
- NativeAOT.targets: adds to IlcReference, UnmanagedEntryPointsAssembly,
  and TrimmerRootAssembly

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tests using MockBuildEngine:
- Empty assembly list succeeds with no outputs
- Real Mono.Android.dll produces per-assembly + root typemap assemblies
- Different TargetFrameworkVersion formats all parse correctly

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Full build integration tests:
- Build with _AndroidTypeMapImplementation=trimmable succeeds on CoreCLR
- Incremental build skips _GenerateJavaStubs when nothing changed

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

- Extract Phase 1-5 into named methods (ScanAssemblies, GenerateTypeMapAssemblies,
  GenerateJcwJavaSources) — no more // Phase N comments
- Filter BCL assemblies: skip FrameworkAssembly=true unless HasMonoAndroidReference
- Throw on unparseable TargetFrameworkVersion instead of silent fallback
- Use LINQ for grouping peers by assembly and filtering paths
- Deterministic output ordering via OrderBy on assembly name

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Both GenerateTypeMapAssemblies and GenerateJcwJavaSources now return
  ITaskItem[] directly — no intermediate conversion in RunTask
- Move Java output under typemap dir (typemap/java instead of android/src)
- Remove TrimmerRootAssembly from generated assemblies — the trimmer must
  process TypeMapAttributes and trim entries whose trimTarget types were
  removed. TrimmerRootAssembly would prevent this, defeating the purpose.
- NativeAOT: keep IlcReference + UnmanagedEntryPointsAssembly but remove
  TrimmerRootAssembly for the same reason.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Compare source assembly timestamp against generated typemap .dll — skip
emission when the output is newer. Root assembly only regenerated when
any per-assembly typemap changed.

Typical incremental build: only app assembly changed → scan all (for
cross-assembly resolution) but only emit _MyApp.TypeMap.dll + root.
Mono.Android scan is unavoidable (xref resolution) but its typemap
emission is skipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- SecondRun_SkipsUpToDateAssemblies: run twice with same inputs,
  verify typemap file timestamp unchanged and 'up to date' logged
- SourceTouched_RegeneratesOnlyChangedAssembly: touch source assembly,
  verify typemap is regenerated with newer timestamp
- InvalidTargetFrameworkVersion_Throws: verify ArgumentException
- Extracted CreateTask helper to reduce test boilerplate
- ParsesTargetFrameworkVersion converted to [TestCase] parameterized test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Both CoreCLR and NativeAOT need to know the TypeMap entry assembly.
RuntimeHostConfigurationOption works for both (runtimeconfig.json for
CoreCLR, ILC feature switch for NativeAOT).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Make types consumed by the MSBuild task public (they're build-time only,
not shipped in apps): JavaPeerInfo, MarshalMethodInfo, JniParameterInfo,
JavaConstructorInfo, ActivationCtorInfo, ActivationCtorStyle,
JavaPeerScanner, TypeMapAssemblyGenerator, RootTypeMapAssemblyGenerator,
JcwJavaSourceGenerator.

Fix Execute_InvalidTargetFrameworkVersion test: AndroidTask catches
exceptions and logs them as errors, so Assert.Throws doesn't work.
Check task.Execute() returns false and errors are logged instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Generate Java content to StringWriter first, compare with existing file.
Only write to disk if content changed. This avoids unnecessary javac
recompilation on incremental builds where types haven't changed.

Benchmark showed JCW file writing was the biggest bottleneck (~511ms p50
for 315 files). With this change, incremental builds that don't change
any types skip all disk writes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@simonrozsival simonrozsival force-pushed the dev/simonrozsival/trimmable-typemap-05-msbuild-task branch from 81102b5 to 791bd4d Compare March 13, 2026 06:53
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.

1 participant