Skip to content

[native] Investigation: GC bridge gref churn is not the cause of CoreCLR ART GC regression (informational, do not merge) - #12263

Draft
jonathanpeppers wants to merge 1 commit into
mainfrom
jonathanpeppers-gc-bridge-gref-churn
Draft

[native] Investigation: GC bridge gref churn is not the cause of CoreCLR ART GC regression (informational, do not merge)#12263
jonathanpeppers wants to merge 1 commit into
mainfrom
jonathanpeppers-gc-bridge-gref-churn

Conversation

@jonathanpeppers

Copy link
Copy Markdown
Member

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, missing WaitForGCBridgeProcessing barrier).

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.GlobalReferenceCount because it is a native-side count.

What I did

Both GC bridges use the identical algorithm, confirmed by reading the code:

  • prepare: iterate every SCC × object, flip each peer strong→weak (NewWeakGlobalRef + DeleteGlobalRef)
  • java.lang.Runtime.gc()
  • cleanup: iterate every SCC × object, flip each peer weak→strong (NewGlobalRef + DeleteWeakGlobalRef)

= exactly 4 JNI global-ref ops per peer per round on both runtimes.

  • CoreCLR: BridgeProcessingShared::process()src/native/clr/host/bridge-processing.cc
  • Mono: OSBridge::gc_cross_references()src/native/mono/monodroid/osbridge.cc

I added one unconditional log_warn per round to each, so the per-round churn can be compared directly against ART's GC wall time:

GCBRIDGE_CHURN runtime=<coreclr|mono> sccs=<n> peers=<n> xrefs=<n> jni_transitions=<n>

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:

Arm Rnd peers/round JNI transitions/round ART GC avg ART GC max FPS
CoreCLR 1 442 1768 20.9 ms 22.0 ms 59.77
CoreCLR 2 442 1767 22.9 ms 26.4 ms 59.76
Mono 1 503 2012 10.5 ms 10.9 ms 59.87
Mono 2 502 2010 10.6 ms 11.1 ms 59.82

ART GC columns are Explicit concurrent copying GC total wall time, filtered to the benchmark process.

  • CoreCLR issues ~12% fewer JNI global-ref transitions per round than Mono (≈1767 vs ≈2011).
  • Yet CoreCLR's ART concurrent GC is ~2.1× slower (≈21.9 ms vs ≈10.6 ms).
  • The relationship is inverted: the runtime doing more gref churn (Mono) is the faster one.

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 == peers and xrefs == 0. Every bridged object is a singleton strongly-connected component with no cross-references, so monodroidAddReference / 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)

Hypothesis Result
CoreCLR holds a larger JNI global-ref root set Dead (#12262) — ~840 on all arms; CoreCLR holds ~3% fewer
Bridge thread runs at a lower priority Dead (#12262) — both nice −10 / PRI 29
Missing WaitForGCBridgeProcessing barrier Dead (#12262) — restored, verified firing, no gain
CoreCLR issues more native gref transitions per round Dead (this branch) — CoreCLR issues fewer, yet is 2.1× slower
SCC circular-ref / add/clear-reference machinery Dead (this branch) — never exercised; xrefs==0, sccs==peers

Where 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:

  • Per-object ART work resolving each weak global — differences in the Java-side peer wrapper objects / GCHandle registration between the two runtimes, rather than the number of transitions.
  • Scheduling of 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_warn appended 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 DEBUG before any real use.

Fixes nothing. Closes nothing. Recorded so the next person doesn't repeat it. Related: dotnet/runtime#131370, #12262.

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

Copy link
Copy Markdown
Member Author

Root cause found via Perfetto: DVFS frequency starvation of the GC-bridge thread

Following the negative gref-churn result above, I captured matched Perfetto system traces (ftrace sched_switch + cpu_frequency + ART atrace) for both runtimes on the Pixel 5, three Explicit concurrent copying GC events inside each 20 s window. This identifies the actual mechanism.

1. The ART GC runs single-threaded on the bridge thread, with zero contention

On both runtimes the ART Explicit concurrent copying GC runs synchronously, single-threaded, on one big core (cpu6), as a single unbroken scheduling segment. HeapTaskDaemon sleeps the whole time. Wall-time == on-CPU time, so it is not CPU contention, not scheduling preemption, and not more threads competing.

  • CoreCLR carrier: Thread-7 (the dedicated GC-bridge pthread) — cpu6, 1 segment, state=Running the whole window.
  • Mono carrier: GLThread 3 (the render thread) — cpu6/cpu7, 1 segment.

2. Every ART scan/copy sub-phase is uniformly ~2.2× slower on CoreCLR — but the heap is identical

Sub-phase totals across the 3 GCs (ms):

ART sub-phase CoreCLR Mono ratio
CopyingPhase 64.73 28.98 2.23×
└ Process mark stacks & References 46.94 20.73 2.26×
ScanImmuneSpaces 7.96 3.81 2.09×
VisitConcurrentRoots 7.96 3.48 2.29×
ReclaimPhase / RecordFree 5.13 / 4.71 2.75 / 2.50 ~1.9×
SweepSystemWeaks 0.53 0.47 1.1× (parity)

The ART heap logs are identical: CoreCLR 3693–3745 KB/27 MB, 86 % free, ~400 KB freed; Mono 3648–3664 KB/27 MB, 87 % free, ~450 KB freed. Same live bytes, same garbage freed, same core — yet every phase costs 2.2×. That means ART does the same work at ~half the clock speed. (SweepSystemWeaks at parity independently re-confirms weak-global churn is not the differentiator.)

3. The CPU is clocked ~2.3× lower during CoreCLR's GC

Time-weighted frequency of the carrier core inside each GC window:

carrier thread avg freq during GC GC wall-time
CoreCLR Thread-7 (dedicated GC-bridge pthread) 866 / 941 / 1094 MHz 27 / 25 / 20 ms
Mono GLThread 3 (render thread) 2090 / 2208 / 2400 MHz 12 / 11 / 10 ms

The frequency ratio (~2.3×) matches the GC-time ratio (~2.2×). Governor is schedutil (utilization-driven DVFS). CoreCLR's cpu6 was seen at 653 MHz — the lowest step in the table — 18 ms into the GC, only ramping to 941 MHz by 25 ms: the governor ramps during the GC but the burst ends before it reaches max.

4. Why: the bridge thread is idle 99.6 % of the time

Carrier-thread duty cycle over the 20 s trace:

carrier on-CPU duty segments
CoreCLR Thread-7 79.8 ms 0.4 % 9 (sleeps 7–8 s on a semaphore between bridge rounds)
Mono GLThread 3 19022 ms 95.3 % 1350 (busy every frame)

GCBridge::bridge_processing() (src/native/clr/host/gc-bridge.cc:101) loops: wait_for_shared_args() (long semaphore sleep) → process() which calls Runtime.gc() — and ART runs the explicit concurrent-copying GC synchronously on this thread. Because the thread has near-zero scheduler utilization when it wakes, schedutil keeps its core near the bottom of the frequency table for the entire short GC burst. Mono has no dedicated bridge thread here — the explicit GC is driven from the always-hot render thread, whose core is already pinned at max clock, so the identical GC finishes in ~half the time.

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 schedutil ramps the CPU up during the rare, latency-critical bridge GC: sched_setattr() with SCHED_FLAG_UTIL_CLAMP_MIN and sched_util_min near 1024, set once after pthread_create in GCBridge::start_bridge_processing_thread (or around the process() round). uclamp_min only affects frequency while the thread is actually on-CPU (0.4 % duty), so the power cost is negligible. Optionally combine with big-cluster affinity. Expected effect: CoreCLR explicit-GC wall-time ~24 ms → ~10 ms (Mono parity), removing the periodic 60→54 FPS dips. This still needs a build + on-device A/B to confirm.

(No root on the device, so I could not force the governor to performance as an independent check; the uclamp build is the validation path.)

Related: dotnet/runtime#131370.

@jonathanpeppers jonathanpeppers added the do-not-merge PR should not be merged. label Jul 30, 2026
jonathanpeppers added a commit that referenced this pull request Jul 30, 2026
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
jonathanpeppers added a commit that referenced this pull request Jul 30, 2026
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
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