[Mono.Android] Investigation: restoring WaitForGCBridgeProcessing() on CoreCLR (informational, do not merge) - #12262
Draft
jonathanpeppers wants to merge 1 commit into
Draft
Conversation
Context: dotnet/runtime#131370 `WaitForGCBridgeProcessing()` was a no-op on CoreCLR/NativeAOT since #11119 removed it. Mono still blocks for the duration of a bridge round via `mono_gc_wait_for_bridge_processing()`, so the two runtimes had different observable behavior for threads entering managed code from Java while a round is in progress. This restores the Mono semantics on CoreCLR, driven by the existing `BridgeProcessingStarted`/`BridgeProcessingFinished` callbacks which already bracket exactly the right window. It is a semantics change, not a performance fix. It was measured as performance-neutral -- see the PR description for the numbers. Gated by a new trimmer feature switch, `Microsoft.Android.Runtime.RuntimeFeature.WaitForGCBridgeProcessing`, settable via the private `$(_AndroidWaitForGCBridgeProcessing)` MSBuild property. When disabled the trimmer removes the barrier entirely. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ad745abd-8179-4bd6-aef3-77978272e69a
This was referenced Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
Informational only — not intended for review or merge.
This branch exists to record a measured negative result for dotnet/runtime#131370, so the next person doesn't re-run this experiment. The code change works and is verified, but it does not fix the reported problem. Nobody needs to review it.
Background
dotnet/runtime#131370 reports periodic frame-rate drops (60→54 FPS every ~5 s) in a .NET MAUI + SkiaSharp game after moving from Mono to CoreCLR. CoreCLR became the default runtime in .NET 11, so this surfaces for apps that did nothing but upgrade.
JniRuntime.JniValueManager.WaitForGCBridgeProcessing()has been an intentional no-op on CoreCLR/NativeAOT since #11119 removed it, while Mono still blocks for the duration of a bridge round viamono_gc_wait_for_bridge_processing(). That difference was the leading hypothesis: a real, deliberate Mono↔CoreCLR behavioral divergence in exactly the code path under suspicion.So I implemented it, and then measured it.
The result: performance-neutral
Before trusting any numbers, I verified the barrier actually fires. Temporary instrumentation in
BridgeProcessingFinishedreported exactly one thread parking per round, 15–36 ms:(An earlier round of this experiment was invalid —
-t:PackDotNetsilently didn't refreshbin/Release/dotnet/packs/**, so both arms linked a byte-identical unmodifiedMono.Android.dll. Worth knowing if you reproduce this locally: mirror the whole pack directory, not individual files.)All three APKs below were built from the same local SDK, run on the same device (Pixel 5, Android 14, arm64, refresh rate pinned to 60 Hz), 60 s each, two rounds in reversed order to control for ordering:
GC columns are ART
Explicit concurrent copying GCwall time in ms;grefisJniEnvironment.Runtime.GlobalReferenceCount.What this rules out
Four hypotheses have now been measured and killed on this benchmark:
ps -Tshows both at nice −10 / PRI 29WaitForGCBridgeProcessingbarrier(d) Native per-round global-ref churn
Measured separately by instrumenting both bridges with one
log_warnper round emittingGCBRIDGE_CHURN runtime=… sccs=… peers=… xrefs=… jni_transitions=…, two reversed-order rounds on the same device.The algorithm is identical on both runtimes. CoreCLR
BridgeProcessingShared::process()and MonoOSBridge::gc_cross_references()both iterate all sccs × objects, flipping every peer strong→weak beforeRuntime.gc()(NewWeakGlobalRef+DeleteGlobalRef) and weak→strong after (NewGlobalRef+DeleteWeakGlobalRef) — exactly 4 JNI global-ref ops per peer.CoreCLR issues ~12% fewer transitions per round yet pays ~2.1× the ART concurrent GC cost. The relationship is inverted — the runtime doing more churn is the faster one.
That run also showed
sccs == peersandxrefs == 0every round on both runtimes: every bridged peer is a singleton SCC with no cross-references, somonodroidAddReference/monodroidClearReferencesand the circular-reference / temporary-peer machinery are never exercised by this benchmark either.What's left
Both runtimes call
java.lang.Runtime.gc()identically, the ART stop-the-world pause is microseconds on both (24–57 µs), heap occupancy is identical (~86% free, 3.7/27 MB), the trigger cadence matches, the managed root set matches, and the native transition count matches (slightly favoring CoreCLR). The entire delta is in ART's concurrent phase.dotnet/runtime has no ART knowledge —
GCBridge::mark_cross_referencesjust publishes to a semaphore — so whatever this is, it lives in dotnet/android.The cause is not the count of anything the bridge does. Remaining suspects concern per-object work and scheduling:
GCHandleregistration between the two runtimes, which would change how much work ART does resolving each weak global.Runtime.gc()relative to ART's concurrent worker threads.Unrelated but noted while debugging: the CoreCLR bridge thread is created as an anonymous
pthreadingc-bridge.cc, so it shows up asThread-Ninps -Twhile Mono's equivalent is the namedFinalizerthread. Apthread_setname_npcall there would be diagnosability-only, but it cost real time in this investigation.The code, for the record
If anyone does want to revive this, what's here is complete and correct:
JavaMarshalRegisteredPeers.WaitForBridgeProcessing()parks on aManualResetEventSlim(spinCount: 0) reset for the duration of a round, driven by the existingBridgeProcessingStarted/BridgeProcessingFinishedcallbacks that already bracket exactly the right window.BridgeProcessingFinishedsignals from afinally, so an exception during processing can't deadlock every Java→managed transition in the app.Microsoft.Android.Runtime.RuntimeFeature.WaitForGCBridgeProcessing, settable via the private$(_AndroidWaitForGCBridgeProcessing)MSBuild property (verified reachingruntimeconfig.jsonin both directions). When disabled, the trimmer removes the barrier entirely.The rationale from #11119 for removing it in the first place still holds, and is preserved in the code comments: the wait cannot prevent the race it appears to guard (only shrink the window), and CoreCLR's JNI wrapper threads hold their own handle copies via
JniObjectReference, so they don't observe the bridge swappingcontrol_blockhandles.