[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. |
The CoreCLR GC bridge runs the explicit ART GC (Runtime.gc()) synchronously on a dedicated pthread that is idle >99% of the time. When it wakes, Android's schedutil DVFS governor sees a cold thread and keeps its core near the bottom of the frequency table for the whole short GC burst, so the GC runs ~2x slower than under MonoVM (which drives the same GC from the always-hot render thread). This surfaces as periodic frame-time hitches / FPS dips. See dotnet/runtime#131370 and #12263. Fix: create an ADPF (Android Dynamic Performance Framework) APerformanceHint session bound to the bridge thread and report each bridge-GC duration, so the platform clocks the carrier core up during the rare, latency-sensitive collection. ADPF is Google's sanctioned mechanism and works without root, RT priority, or kernel CONFIG_UCLAMP_TASK (unlike a direct sched_setattr() hint). ADPF overview: https://developer.android.com/games/optimize/adpf APerformanceHint (NDK): https://developer.android.com/ndk/reference/group/a-performance-hint android_get_device_api_level: https://developer.android.com/ndk/reference/group/apilevels The APerformanceHint_* API is API 33+, so the newer symbols are weak-linked and every call is guarded by a runtime android_get_device_api_level() >= __ANDROID_API_T__ check; the whole path fails soft (a scheduling hint must never abort the process). Gated by a new private MSBuild property _AndroidGCBridgeThreadBoost (default true), plumbed through to the CoreCLR ApplicationConfig as gc_bridge_thread_boost_enabled. On-device (Pixel 10, Tensor G5, API 36; Perfetto, screen-on, 45s per arm, 6-7 GC events each): median carrier-core frequency during the explicit GC rose 1000 -> 1751 MHz (+75%) and explicit-GC wall-time fell 18.8 -> 13.2 ms mean (-30%), median 19.4 -> 12.9 ms (-34%). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea778d78-47ba-4349-8857-9dd3c91be5ca
The CoreCLR GC bridge runs the explicit ART GC (Runtime.gc()) synchronously on a dedicated pthread that is idle >99% of the time. When it wakes, Android's schedutil DVFS governor sees a cold thread and keeps its core near the bottom of the frequency table for the whole short GC burst, so the GC runs ~2x slower than under MonoVM (which drives the same GC from the always-hot render thread). This surfaces as periodic frame-time hitches / FPS dips. See dotnet/runtime#131370 and #12263. Fix: create an ADPF (Android Dynamic Performance Framework) APerformanceHint session bound to the bridge thread and report each bridge-GC duration, so the platform clocks the carrier core up during the rare, latency-sensitive collection. ADPF is Google's sanctioned mechanism and works without root, RT priority, or kernel CONFIG_UCLAMP_TASK (unlike a direct sched_setattr() hint). ADPF overview: https://developer.android.com/games/optimize/adpf APerformanceHint (NDK): https://developer.android.com/ndk/reference/group/a-performance-hint android_get_device_api_level: https://developer.android.com/ndk/reference/group/apilevels The APerformanceHint_* API is API 33+, so the newer symbols are weak-linked and every call is guarded by a runtime android_get_device_api_level() >= __ANDROID_API_T__ check; the whole path fails soft (a scheduling hint must never abort the process). The weak ADPF symbol itself is also null-checked before the first call, so on runtimes/devices where libandroid does not provide it the path skips instead of calling through a null pointer. Gated by a new private MSBuild property _AndroidGCBridgeThreadBoost (default true), plumbed through to the CoreCLR ApplicationConfig as gc_bridge_thread_boost_enabled. gc-bridge.cc is shared with the NativeAOT host, which does not link the generated application_config symbol; under XA_HOST_NATIVEAOT the boost is enabled unconditionally (and still fails soft) so the shared source links without dragging in application_config, which is not provided at NativeAOT app-link time. On-device (Pixel 10, Tensor G5, API 36; Perfetto, screen-on, 45s per arm, 6-7 GC events each): median carrier-core frequency during the explicit GC rose 1000 -> 1751 MHz (+75%) and explicit-GC wall-time fell 18.8 -> 13.2 ms mean (-30%), median 19.4 -> 12.9 ms (-34%). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea778d78-47ba-4349-8857-9dd3c91be5ca
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.