镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

[TrimmableTypeMap] Avoid assembly reflection during peer lookup - #13526

Open
simonrozsival wants to merge 4 commits into
mainfrom
simonrozsival-issue-13421-investigation
Open

simonrozsival wants to merge 4 commits into
mainfrom
simonrozsival-issue-13421-investigation

Conversation

@simonrozsival

@simonrozsival simonrozsival commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Fixes #13421.
Context: #12644.

The sealed-peer shortcut checks Type.Assembly on every lookup. NativeAOT resolves this through runtime metadata, making the check disproportionately expensive. Replace it with a generator-selected JavaPeerProxyForSealedFrameworkClass base for sealed types defined in Mono.Android; the runtime recognizes that marker without adding a field to each proxy.

The Mono.Android restriction preserves the existing class-loader safeguard. FindClass can resolve an app class using a loader different from the one that created the Java object; classes with the same name from different loaders have different identities. An IsInstanceOf mismatch can therefore be inconclusive for external peers, which continue to use hierarchy lookup. Android normally uses separate framework and app loaders, and additional loaders can be created at runtime, so build inputs cannot generally establish which class identities will be involved in a lookup.

CanResolveFromSealedTargetType is generator-model metadata, not a second runtime marker. It selects the emitted proxy base and is included in both the incremental-build fingerprint and the deterministic MVID content fingerprint: changing a peer's eligibility changes the emitted assembly even when its type name is unchanged. This prevents a stale incremental output and gives different emitted content a different MVID.

Cover generated bases and constructors across framework/external, sealed/unsealed, alias, and generic shapes; incremental invalidation and deterministic MVIDs; and on-device sealed success/mismatch, nonsealed hierarchy lookup, external peers, and closed generics.

NativeAOT measurements

A fully trimmed Release android-arm64 instrumentation app repeatedly creates managed peers using Java.Lang.Object.GetObject<T> from retained JNI references, disposing the wrappers between batches. Android 15 ARM64 emulator on Apple Silicon; baseline a008bb3473bfa512f9f6ce5823ea2bf01d82981f versus this change, same app source and build settings. Each case uses 20 warmup batches and 80 measured batches of 4,096 lookups per process. Six interleaved installs/runs (before, after, after, before, before, after), without profiling during timing: 240 measured batches per case/variant. GC and disposal are outside the timed regions.

Target Before, median ns/lookup After, median ns/lookup Time reduction Allocated bytes/lookup, before → after
Sealed Java.Lang.String 4,946 2,007 59.4% 2,120.7 → 56
Sealed app peer 4,113 2,472 39.9% 1,408.4 → 232

Per-run medians do not overlap: framework 4,879–5,046 ns before versus 1,955–2,051 ns after; app peer 4,016–4,234 ns versus 2,401–2,531 ns. Separate 4 kHz cpu-clock simpleperf captures with native symbols show NativeFormatRuntimeNamedTypeInfo.get_Assembly at 34.29% inclusive samples before and no sampled calls after. Both APKs contain the NativeAOT library, and instrumentation asserts that dynamic code is unsupported.

CoreCLR measurements

The same app and workload, rebuilt with PublishAot=false and AndroidRuntime=CoreCLR, ran twice with the same six-run interleaving above: six processes and 480 measured batches per case/variant in total. The APKs contain libcoreclr.so; instrumentation verifies dynamic-code support and the trimmable typemap value manager.

Target Before, pooled median ns/lookup After, pooled median ns/lookup Per-run median range, before → after Allocated bytes/lookup, before → after
Sealed Java.Lang.String 2,137 1,961 1,949–4,797 → 1,845–2,669 56 → 56
Sealed app peer 2,610 2,343 2,054–3,206 → 2,011–2,496 232 → 232

The ranges overlap substantially, so these CoreCLR results do not establish a measurable speedup. The first before run was especially variable; the second round's pooled framework medians were 1,997 ns before and 1,935 ns after.

For this CoreCLR baseline, make prepare CONFIGURATION=Release passed. A clean IncludeAndroidJavadoc=True make leeroy CONFIGURATION=Release failed in Javadoc generation due to missing copyright metadata. IncludeAndroidJavadoc=False make leeroy CONFIGURATION=Release built the baseline binaries and extra API levels but failed packaging because Mono.Android.xml was absent. Copying that documentation XML from the changed SDK and running IncludeAndroidJavadoc=False ./bin/Release/dotnet/dotnet build -p:Configuration=Release -t:ConfigureLocalWorkload build-tools/create-packs/Microsoft.Android.Sdk.proj completed packaging. Thus the baseline used its own runtime and generator binaries but differs in documentation-generation settings; the copied XML is packaging input, not benchmark code.

These are peer-lookup microbenchmark results, not PoolMath, MAUI scrolling, physical-device, or whole-app speedup claims.

Local validation

  • make prepare CONFIGURATION=Release — passed.
  • IncludeAndroidJavadoc=True make leeroy CONFIGURATION=Release — passed before and after, including extra API levels and local workload configuration. The initial invocation without documentation enabled failed because packaging required Mono.Android.xml; enabling the existing documentation option resolved it without build-file changes.
  • The following host command — 128 passed, no failures or skips:
./dotnet-local.sh test src/Microsoft.Android.Build.Tasks/Tests/Microsoft.Android.Build.Tasks.Tests/Microsoft.Android.Build.Tasks.Tests.csproj -c Release -v minimal --filter 'FullyQualifiedName~TrimmableTypeMap'
On-device commands: NativeAOT 20 passed; CoreCLR 20 passed

Both configurations built, installed, and passed all 20 selected tests on the ARM64 emulator. Existing SourceLink and trim/AOT-analysis warnings remain visible.

./dotnet-local.sh build -t:Install -c Release -p:PublishAot=true -p:RuntimeIdentifier=android-arm64 -p:IncludeCategories=NativeAOTTrimmable '-p:AdbTarget=-s emulator-5554' tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj -v minimal
(cd tests/Mono.Android-Tests/Mono.Android-Tests && ../../../dotnet-local.sh test Mono.Android.NET-Tests.csproj --no-build -c Release -p:PublishAot=true -p:RuntimeIdentifier=android-arm64 -p:IncludeCategories=NativeAOTTrimmable '-p:AdbTarget=-s emulator-5554' --report-trx --results-directory ../../../bin/TestRelease/Issue13421NativeAotResults)

./dotnet-local.sh build -t:Install -c Release -p:PublishAot=false -p:AndroidRuntime=CoreCLR -p:RuntimeIdentifier=android-arm64 -p:IncludeCategories=NativeAOTTrimmable '-p:AdbTarget=-s emulator-5554' tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj -v minimal
(cd tests/Mono.Android-Tests/Mono.Android-Tests && ../../../dotnet-local.sh test Mono.Android.NET-Tests.csproj --no-build -c Release -p:PublishAot=false -p:AndroidRuntime=CoreCLR -p:RuntimeIdentifier=android-arm64 -p:IncludeCategories=NativeAOTTrimmable '-p:AdbTarget=-s emulator-5554' --report-trx --results-directory ../../../bin/TestRelease/Issue13421CoreClrResults)

NativeAOT repeatedly constructs reflection assembly metadata while checking
whether a sealed target belongs to Mono.Android.  Move that eligibility
check into typemap generation and encode it in a marker proxy base class,
without adding state to each proxy instance.

Keep external peers on the Java hierarchy lookup path because they may
use a different class loader.  Include the generated base in typemap
fingerprints and cover aliases, generic targets, and runtime fallback.

Fixes #13421.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings November 12, 2026 17:49

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.

🟡 Changes recommended

The alias test cases emit no proxies, so their assertions never run and base-class and constructor selection remain untested.

1 open issue
What changed in this PR

Addresses #13421 by moving sealed-framework peer eligibility checks from runtime reflection into generated proxy types. This reduces NativeAOT peer-lookup overhead while preserving hierarchy lookup for external peers that may use different class loaders.

Changes:

  • Adds a marker base for sealed Mono.Android peers and uses it during runtime lookup.
  • Propagates eligibility through scanning, generation, constructor emission, and both typemap fingerprints.
  • Adds regression tests and updates the public API baseline and interop documentation.
File Description
tests/​Mono.Android-Tests/​Mono.Android-Tests/​Java.Interop/​TrimmableTypeMapTypeManagerTests.cs Extends device tests for marker eligibility and hierarchy lookup.
src/​Mono.Android/​PublicAPI/​API-37/​PublicAPI.Unshipped.txt Records the new marker base API.
src/​Mono.Android/​Microsoft.Android.Runtime/​TrimmableTypeMap.cs Replaces reflection-based eligibility checks with marker detection.
src/​Mono.Android/​Java.Interop/​JavaPeerProxy.cs Defines the marker base without additional instance fields.
src/​Microsoft.Android.Sdk.TrimmableTypeMap/​Scanner/​JavaPeerScanner.cs Captures sealed-type metadata.
src/​Microsoft.Android.Sdk.TrimmableTypeMap/​Scanner/​JavaPeerInfo.cs Stores sealed status in scanner results.
src/​Microsoft.Android.Sdk.TrimmableTypeMap/​Generator/​TypeMapAssemblyEmitter.cs Emits the selected base and matching constructor reference.
src/​Microsoft.Android.Sdk.TrimmableTypeMap/​Generator/​ModelBuilder.cs Restricts marker eligibility to sealed Mono.Android types.
src/​Microsoft.Android.Sdk.TrimmableTypeMap/​Generator/​Model/​TypeMapAssemblyData.cs Carries eligibility in the generation model.
src/​Microsoft.Android.Sdk.TrimmableTypeMap/​Generator/​MetadataHelper.cs Includes eligibility in both fingerprints.
src/​Microsoft.Android.Build.Tasks/​Tests/​Microsoft.Android.Build.Tasks.Tests/​TrimmableTypeMapIncrementalTests.cs Tests invalidation and deterministic identity changes.
src/​Microsoft.Android.Build.Tasks/​Tests/​Microsoft.Android.Build.Tasks.Tests/​GenerateTrimmableTypeMapTests.cs Adds parameterized base-class and constructor checks.
Documentation/​guides/​internals/​JavaJNI_Interop.md Explains marker-based lookup and class-loader restrictions.

Review effort: Balanced

@simonrozsival simonrozsival changed the title [Mono.Android] Avoid assembly reflection during peer lookup [TrimmableTypeMap] Avoid assembly reflection during peer lookup Nov 12, 2026
@simonrozsival

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

✅ Android PR Reviewer completed successfully!

Generated by Android PR Reviewer for #13526

The sealed-peer generator test used alias peers without activation constructor metadata. Alias groups therefore emitted no proxies, making the loop over generated proxy types vacuous for those cases. Give both peers activation metadata and assert that the expected proxies are emitted before checking their base and constructor references.

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

@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.

⚠️ Awaiting CI — no blocking code findings.

Findings: 0 ❌ errors · 0 ⚠️ warnings · 1 💡 non-blocking inline testing suggestion.

The scanner-to-model-to-emitter path consistently limits the marker base to sealed Mono.Android targets, and the emitted constructor calls the selected base. Including eligibility in both fingerprints correctly covers incremental invalidation and deterministic MVID changes. The runtime retains the existing remapping, assignability check, and JNI-reference cleanup. The latest test-only commit c315011017 also fixes the previously vacuous alias cases by supplying activation metadata and asserting the proxy count.

CI: GitHub reports five Azure DevOps checks in progress, the aggregate check queued, and CLA passing for the current head. Azure build 1678182 is not confirmed green; the direct Azure build query requires authentication, so deeper pipeline status was unavailable. The PR's reported local test and benchmark results were not independently rerun during this review.

Generated by Android PR Reviewer for #13526 · copilot · gpt61 · 63.6 AIC · ⌖ 7.58 AIC · ⊞ 19.5K
Comment /review to run again

simonrozsival and others added 2 commits November 12, 2026 21:59
Context: #13526

The existing external-peer test created its Java object through the runtime loader, so it did not cover duplicate class identity across loaders. Load the fixture from the app APK using a PathClassLoader with a null parent, assert the object is not an instance of the runtime-loader class, and verify typemap lookup still chooses the unmarked external proxy.

The Release on-device runtime suite passed: 1,057 passed, 8 skipped.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CA1422 fails the build because `Class.NewInstance()` is obsoleted on API-34+. Use `Class.GetConstructor().NewInstance()` instead.

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

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

✅ Android PR Reviewer completed successfully!

Generated by Android PR Reviewer for #13526

@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.

Code assessment: no blocking findings. 0 ❌ errors, 0 ⚠️ warnings, 1 💡 nonblocking inline suggestion.

The scanner-to-model-to-emitter change consistently selects the marker base and its constructor only for sealed Mono.Android targets. The runtime retains the class-loader safeguard for external peers, and the eligibility bit participates in both incremental and content fingerprints. The generated-base/constructor matrix, incremental invalidation checks, and on-device class-loader and generic cases provide useful coverage. The documented NativeAOT measurements support the narrowly stated performance claim; the description appropriately avoids claiming a demonstrated CoreCLR or whole-app speedup.

CI verification is incomplete: all 40 GitHub checks report success, including the aggregate dotnet-android check for Azure build 1679658. Direct Azure build/timeline verification was blocked: the build query required authentication, and the timeline query failed during CLI initialization. I therefore cannot independently confirm the Azure result or per-stage evidence. This was a static review of head d758a06740f800b81fc2e055fa0c51465bcff580; host/device tests and benchmarks were not rerun.

Generated by Android PR Reviewer for #13526 · copilot · gpt61 · 59.1 AIC · ⌖ 4.74 AIC · ⊞ 20.2K
Comment /review to run again


var targetProxy = GetProxyForManagedType (targetType);
if (targetProxy is null) {
GetProxyForManagedType (targetType) is not JavaPeerProxyForSealedFrameworkClass targetProxy) {

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.

🤖 💡 Performance — Could you add a nonsealed target, such as Java.Lang.Object, to the before/after lookup measurements? Previously !targetType.IsSealed returned before GetProxyForManagedType; now every non-null nonsealed target also performs the managed-proxy lookup (including generic normalization and, on a cold cache, attribute instantiation) before falling back to hierarchy traversal. The sealed framework/app measurements establish the intended improvement, but do not cover this additional work on the nonsealed path. A nonsealed case would help establish that the optimization does not trade that improvement for a regression in common base-type lookups.

{Rule: Consider startup-time impact; place cheap checks before expensive ones}

@simonrozsival simonrozsival added the ready-to-review This PR is ready to review/merge, I think any CI failures are just flaky (ignorable). label Nov 13, 2026
@simonrozsival
simonrozsival enabled auto-merge (squash) November 13, 2026 16:02
Comment on lines +9 to +14
/// <summary>
/// Base class for generated proxies whose sealed Mono.Android target can be resolved
/// using the runtime's class loader without inspecting the Java object's hierarchy.
/// </summary>
[EditorBrowsable (EditorBrowsableState.Never)]
public abstract class JavaPeerProxyForSealedFrameworkClass : JavaPeerProxy

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we need a new class? (this one has an odd name) Or could we make a new JavaPeerProxy.IsSealed property?

@simonrozsival simonrozsival removed the ready-to-review This PR is ready to review/merge, I think any CI failures are just flaky (ignorable). label Nov 13, 2026

This branch has not been deployed

No deployments
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.

[TrimmableTypeMap] NativeAOT: TryResolveProxyFromSealedTargetType calls Type.Assembly on every peer lookup (≈98% of its cost)

3 participants