[native] Investigation: GC bridge gref churn is not the cause of CoreCLR ART GC regression (informational, do not merge) - #12263
Conversation
Adds one unconditional per-round summary line to both GC bridge implementations to test whether CoreCLR issues far more JNI weak/strong global-reference transitions per bridge round than Mono: GCBRIDGE_CHURN runtime=<> sccs=<> peers=<> xrefs=<> jni_transitions=<> Both runtimes use the identical algorithm: flip every peer strong->weak before Runtime.gc() (NewWeakGlobalRef + DeleteGlobalRef) and weak->strong after (NewGlobalRef + DeleteWeakGlobalRef) = 4 JNI global-ref ops/peer. Measured on Pixel 5 / Android 14 / arm64, 60Hz pinned, two reversed rounds: Arm peers/rd transitions/rd 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 NEGATIVE RESULT: CoreCLR issues ~12% FEWER gref transitions per round than Mono, yet its ART concurrent-copying GC costs ~2.1x more. The relationship is inverted, so per-round native global-ref churn does NOT explain the ~13 ms concurrent-phase delta. Also: sccs==peers and xrefs==0 on both, so the SCC circular-ref and monodroidAddReference paths are never exercised by this benchmark. This hypothesis is dead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9da32e8-e6a5-47bb-b145-3d683378e69b
Root cause found via Perfetto: DVFS frequency starvation of the GC-bridge threadFollowing the negative gref-churn result above, I captured matched Perfetto system traces (ftrace 1. The ART GC runs single-threaded on the bridge thread, with zero contentionOn both runtimes the ART
2. Every ART scan/copy sub-phase is uniformly ~2.2× slower on CoreCLR — but the heap is identicalSub-phase totals across the 3 GCs (ms):
The ART heap logs are identical: CoreCLR 3. The CPU is clocked ~2.3× lower during CoreCLR's GCTime-weighted frequency of the carrier core inside each GC window:
The frequency ratio (~2.3×) matches the GC-time ratio (~2.2×). Governor is 4. Why: the bridge thread is idle 99.6 % of the timeCarrier-thread duty cycle over the 20 s trace:
This is the whole 13 ms concurrent-phase delta, and it is a .NET-side (dotnet/android) design difference, not app behavior and not anything ART-specific. Proposed fix (dotnet/android, .NET-side — not an app workaround)Raise the GC-bridge thread's util-clamp floor so (No root on the device, so I could not force the governor to Related: dotnet/runtime#131370. |
Note
Informational only — not intended for review or merge.
This branch records a measured negative result for dotnet/runtime#131370, so the next person doesn't re-run this experiment. The instrumentation is correct and verified, but it does not fix the reported problem, and it kills one more hypothesis. Nobody needs to review it.
Background
dotnet/runtime#131370 reports periodic frame-rate drops (60→54 FPS every ~5–8 s) in a .NET MAUI + SkiaSharp game after moving from Mono to CoreCLR (the .NET 11 default). A prior investigation (draft PR #12262) established that the entire delta lives in ART's concurrent copying-GC phase — with an identical managed root set (~840 grefs), identical
Runtime.gc()trigger, identical stop-the-world pause (µs), and identical heap occupancy — and killed three hypotheses (larger gref root set, bridge thread priority, missingWaitForGCBridgeProcessingbarrier).This branch tests the last structural difference #12262 called out: does CoreCLR's GC bridge issue far more JNI weak/strong global-reference transitions per bridge round than Mono? That churn is invisible to the managed
JniEnvironment.Runtime.GlobalReferenceCountbecause it is a native-side count.What I did
Both GC bridges use the identical algorithm, confirmed by reading the code:
NewWeakGlobalRef+DeleteGlobalRef)java.lang.Runtime.gc()NewGlobalRef+DeleteWeakGlobalRef)= exactly 4 JNI global-ref ops per peer per round on both runtimes.
BridgeProcessingShared::process()—src/native/clr/host/bridge-processing.ccOSBridge::gc_cross_references()—src/native/mono/monodroid/osbridge.ccI added one unconditional
log_warnper round to each, so the per-round churn can be compared directly against ART's GC wall time:Both instrumented native libs were byte-verified inside the packaged APKs before trusting any numbers.
The result: performance-neutral / inverted
Same local SDK, same device (Pixel 5, Android 14, arm64, refresh rate pinned to 60 Hz), 60 s per arm, two rounds in reversed order to control for ordering. Steady state (startup + first bridge round dropped as warm-up), no contamination:
ART GC columns are
Explicit concurrent copying GCtotal wall time, filtered to the benchmark process.So per-round native global-ref churn does not explain the ~13 ms concurrent-phase delta.
Bonus: also rules out the SCC/cross-ref machinery
On both runtimes, every round reported
sccs == peersandxrefs == 0. Every bridged object is a singleton strongly-connected component with no cross-references, somonodroidAddReference/monodroidClearReferences, the circular-reference wiring, and the temporary-peer path are never exercised by this benchmark. Those are not the differentiator either.What this rules out (cumulative with #12262)
WaitForGCBridgeProcessingbarrierWhere I'd look next
The delta is in ART's concurrent phase under an identical bridge algorithm, identical trigger cadence, identical managed root set, and now near-identical (slightly lower for CoreCLR) native gref churn. The cause is not the count of bridge JNI operations. Remaining structural suspects:
GCHandleregistration between the two runtimes, rather than the number of transitions.Runtime.gc()relative to ART's concurrent worker — whether the bridge thread's timing causes ART's concurrent copy to overlap differently with app allocation on CoreCLR.The code, for the record
Two files, +20 lines, instrumentation only — one per-round summary
log_warnappended to each runtime's existing bridge round entry point. Gated by nothing (always on) purely so the experiment is reproducible; it would be removed or made#if DEBUGbefore any real use.Fixes nothing. Closes nothing. Recorded so the next person doesn't repeat it. Related: dotnet/runtime#131370, #12262.