Skip to content

[Mono.Android] Investigation: restoring WaitForGCBridgeProcessing() on CoreCLR (informational, do not merge) - #12262

Draft
jonathanpeppers wants to merge 1 commit into
mainfrom
jonathanpeppers-gc-bridge-wait-for-bridge-processing
Draft

[Mono.Android] Investigation: restoring WaitForGCBridgeProcessing() on CoreCLR (informational, do not merge)#12262
jonathanpeppers wants to merge 1 commit into
mainfrom
jonathanpeppers-gc-bridge-wait-for-bridge-processing

Conversation

@jonathanpeppers

@jonathanpeppers jonathanpeppers commented Jul 29, 2026

Copy link
Copy Markdown
Member

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 via mono_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 BridgeProcessingFinished reported exactly one thread parking per round, 15–36 ms:

round done: enabled=True blockedThreads=1 blockedTotalMs=20.2
round done: enabled=True blockedThreads=1 blockedTotalMs=36.0
round done: enabled=True blockedThreads=1 blockedTotalMs=17.4

(An earlier round of this experiment was invalid — -t:PackDotNet silently didn't refresh bin/Release/dotnet/packs/**, so both arms linked a byte-identical unmodified Mono.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:

Arm Rnd GCs GC avg GC max FPS max frame gap frames >20 ms gref avg
CoreCLR, barrier ON 1 9 24.7 57.9 59.79 53.6 25 839
CoreCLR, barrier ON 2 9 27.1 74.7 59.78 53.6 28 840
CoreCLR, barrier OFF 1 9 24.6 61.3 59.75 54.3 25 840
CoreCLR, barrier OFF 2 9 21.6 25.7 59.77 57.4 24 840
Mono 1 8 11.5 21.3 59.91 33.6 75 836
Mono 2 8 10.8 14.3 59.90 33.5 70 830

GC columns are ART Explicit concurrent copying GC wall time in ms; gref is JniEnvironment.Runtime.GlobalReferenceCount.

  • ON vs OFF is inside run-to-run noise, and ON is if anything marginally worse.
  • The Mono↔CoreCLR gap is untouched: ~2.3× ART GC wall time and ~1.6× worst frame gap.
  • Mono has more mildly-late frames (70–75) but a bounded ~34 ms worst case; CoreCLR has fewer late frames (24–28) but 54–75 ms spikes. Mono holds a small consistent FPS edge.

What this rules out

Four hypotheses have now been measured and killed on this benchmark:

# Hypothesis Result
a CoreCLR holds a larger JNI global-ref root set Dead — ~840 grefs on all arms; CoreCLR holds ~3% fewer and reclaims fewer peers
b Bridge thread runs at a lower priority Deadps -T shows both at nice −10 / PRI 29
c Missing WaitForGCBridgeProcessing barrier Dead — this branch; restored, verified firing, no gain
d CoreCLR issues more native JNI global-ref transitions per round Dead — see below

(d) Native per-round global-ref churn

Measured separately by instrumenting both bridges with one log_warn per round emitting GCBRIDGE_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 Mono OSBridge::gc_cross_references() both iterate all sccs × objects, flipping every peer strong→weak before Runtime.gc() (NewWeakGlobalRef + DeleteGlobalRef) and weak→strong after (NewGlobalRef + DeleteWeakGlobalRef) — exactly 4 JNI global-ref ops per peer.

Arm peers/round JNI transitions/round ART GC avg ART GC max FPS
CoreCLR ~442 ~1767 21.9 ms 26 ms 59.77
Mono ~502 ~2011 10.6 ms 11 ms 59.85

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 == peers and xrefs == 0 every round on both runtimes: every bridged peer is a singleton SCC with no cross-references, so monodroidAddReference / monodroidClearReferences and 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_references just 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:

  • Differences in the Java-side peer wrapper objects / GCHandle registration between the two runtimes, which would change how much work ART does resolving each weak global.
  • Scheduling of Runtime.gc() relative to ART's concurrent worker threads.

Unrelated but noted while debugging: the CoreCLR bridge thread is created as an anonymous pthread in gc-bridge.cc, so it shows up as Thread-N in ps -T while Mono's equivalent is the named Finalizer thread. A pthread_setname_np call 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 a ManualResetEventSlim (spinCount: 0) reset for the duration of a round, driven by the existing BridgeProcessingStarted/BridgeProcessingFinished callbacks that already bracket exactly the right window.
  • BridgeProcessingFinished signals from a finally, so an exception during processing can't deadlock every Java→managed transition in the app.
  • The bridge thread early-outs on its own thread id, so it can never wait on its own completion signal.
  • Gated by a trimmer feature switch, Microsoft.Android.Runtime.RuntimeFeature.WaitForGCBridgeProcessing, settable via the private $(_AndroidWaitForGCBridgeProcessing) MSBuild property (verified reaching runtimeconfig.json in 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 swapping control_block handles.

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
@jonathanpeppers jonathanpeppers changed the title [Mono.Android] Restore WaitForGCBridgeProcessing() on CoreCLR [Mono.Android] Investigation: restoring WaitForGCBridgeProcessing() on CoreCLR (informational, do not merge) Jul 29, 2026
@jonathanpeppers jonathanpeppers added the do-not-merge PR should not be merged. label Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do-not-merge PR should not be merged.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant