Skip to content

[CoreCLR] Boost GC bridge thread CPU frequency via ADPF - #12269

Draft
jonathanpeppers wants to merge 1 commit into
mainfrom
jonathanpeppers-bridge-thread-uclamp-fix
Draft

[CoreCLR] Boost GC bridge thread CPU frequency via ADPF#12269
jonathanpeppers wants to merge 1 commit into
mainfrom
jonathanpeppers-bridge-thread-uclamp-fix

Conversation

@jonathanpeppers

@jonathanpeppers jonathanpeppers commented Jul 30, 2026

Copy link
Copy Markdown
Member

Problem

Under CoreCLR, MAUI/SkiaSharp apps show periodic frame-time hitches / 60→54 FPS dips that do not happen under MonoVM. Root cause (measured with Perfetto, see #12263 and dotnet/runtime#131370):

The CoreCLR GC bridge runs the explicit ART GC (Runtime.gc()) synchronously on a dedicated pthread (GCBridge::start_bridge_processing_thread) that is idle >99% of the time (it sleeps ~7–8 s on a semaphore between bridge rounds). 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 same ART GC, on the same heap, runs at roughly half the clock and is ~2× slower than under MonoVM (which drives the same GC from the always-hot render thread already pinned near max clock). The stretched stop-the-world sub-phases suspend the mutator threads (including the SkiaSharp GL render thread), producing the hitch.

Fix

Create an ADPF (Android Dynamic Performance Framework) APerformanceHint session bound to the GC-bridge thread and report each bridge-GC duration. The platform then clocks the carrier core up while that thread runs, during the rare, latency-sensitive collection. ADPF is Google's sanctioned mechanism for exactly this and works without root, without RT priority, and independently of the kernel's CONFIG_UCLAMP_TASK setting — unlike a direct sched_setattr() util-clamp hint (which is disabled on many shipping kernels). Because the bridge thread sleeps off-CPU >99% of the time, the boost only applies during the GC, so the power cost is negligible.

Implementation notes:

  • Core change is in src/native/clr/host/gc-bridge.cc: create the session once at bridge-thread start, then time process() and call APerformanceHint_reportActualWorkDuration each round.
  • The APerformanceHint_* API is API 33+ (Android 13). Newer symbols are weak-linked (__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__) and every call is guarded by a runtime android_get_device_api_level() >= __ANDROID_API_T__ check — the native equivalent of a Build.VERSION.SDK_INT check.
  • The whole path fails soft: if the device is too old, has no hint manager, or declines the session, it logs and continues. 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 validation

Measured on a Pixel 10 (Tensor G5, API 36) with Perfetto (cpu_frequency ftrace + dalvik atrace), screen verified on, 45 s per arm, all three arms captured back-to-back under identical conditions on the same benchmark. The metric is the ART "Explicit concurrent mark compact GC" slice's wall-time and the time-weighted CPU frequency its carrier core runs at during the slice.

Arm GC runs on Carrier-core freq (median) GC wall-time (median) GC wall-time (mean) GC events
CoreCLR, boost OFF (baseline) dedicated bridge thread 1000 MHz 19.4 ms 18.8 ms 6
CoreCLR, boost ON (this PR) dedicated bridge thread 1751 MHz 12.9 ms 13.2 ms 7
MonoVM (reference) GL render thread 2512 MHz 8.0 ms 8.0 ms 6

Reading the numbers:

  • The boost raises the bridge thread's carrier core from ~1.0 GHz to ~1.75 GHz and cuts explicit-GC wall-time 19.4 → 12.9 ms (−34% median), tightening the tail (worst-case 21.2 → 16.9 ms) and eliminating the pathological stretched collections. That closes ~57% of the CoreCLR→Mono gap.
  • It does not reach full Mono parity. Mono runs the same ART GC on the always-hot GL render thread, which the platform already keeps on a prime core at ~2.5 GHz; the boosted CoreCLR bridge thread lands on the mid cluster at ~1.75 GHz. The residual ~5 ms is that remaining frequency/placement gap — the dedicated bridge thread is inherently colder than Mono's render thread, and ADPF raised its clamp but the scheduler kept it off the prime cluster.

Follow-ups / TODO

  • Close the residual gap toward Mono: try a lower ADPF target duration and/or prime-cluster CPU affinity for the bridge thread so it lands where Mono's GLThread does (~2.5 GHz), weighed against idle-power cost.
  • Broader device coverage numbers (this validation is Pixel 10 / Tensor G5 only).
  • Confirm no measurable idle-power regression from the persistent session.

Caveats

  • ADPF requires a device whose Power HAL implements hint sessions (Pixel 6+ / modern SoCs). On older hardware (e.g. Pixel 5, whose dumpsys performance_hint reports HAL Support: false) the session is declined and the code falls back to a no-op — the regression persists there, but that hardware also cannot be helped via uclamp or RT priority, which are likewise platform-disabled.

NativeAOT compatibility

src/native/clr/host/gc-bridge.cc is compiled into both the CoreCLR host (libnet-android) and the NativeAOT host (libnaot-android). NativeAOT does not link the generated application_config symbol (it has its own host and never pulls in the CoreCLR config), so referencing application_config.gc_bridge_thread_boost_enabled unconditionally broke every NativeAOT app link (ld: error: undefined symbol: application_config). Under XA_HOST_NATIVEAOT the boost is now enabled unconditionally (still fails soft) and the application_config read is compiled out, so the shared source links cleanly. Verified locally by rebuilding both libnet-android.release.so and libnaot-android.release.so for android-arm64 (both link with -Wl,--no-undefined; the weak ADPF symbols do not trip it).

@jonathanpeppers

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Android PR Reviewer completed successfully!

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Android PR Review — ⚠️ Minor suggestions (draft PR)

Reviewed the ADPF GC-bridge boost change. Overall this is a clean, well-scoped, and unusually well-documented change: the native path fails soft, every ADPF call is correctly gated behind a runtime android_get_device_api_level() >= __ANDROID_API_T__ check with weak-linked symbols, the session is bound to the calling bridge thread via gettid(), and timing is only done when a session exists.

Verified correct:

  • New gc_bridge_thread_boost_enabled field is appended to the CoreCLR ApplicationConfig struct, mirrored in ApplicationConfigCLR.cs, and ApplicationConfigFieldCount_CoreCLR was bumped 20 → 21 with a matching case 20 in EnvironmentHelper.cs. Field ordering is consistent across the native struct, the LLVM-IR generator, and the parser.
  • MSBuild plumbing (_AndroidGCBridgeThreadBoost default true → task GCBridgeThreadBoostGCBridgeThreadBoostEnabled) is wired only into the CLR generator, which is correct since this is a CoreCLR-only concern.
  • ADPF usage (create session with target duration, report actual duration per round) matches the documented API contract, and reporting only happens on API 33+.

Suggestions (non-blocking, both posted inline):

  • 💡 Document the intentionally process-lifetime ADPF session that is never APerformanceHint_closeSession-d, per the repo leak-documentation convention.
  • 💡 Add a regression test asserting the _AndroidGCBridgeThreadBoost toggle propagates into the generated config (default-true and explicit-false).

No correctness or safety issues found. Note this is a draft PR (mergeable_state: blocked) with open TODOs in the description (broader device coverage, idle-power confirmation, switch documentation), so it is not yet ready to merge on its own terms.

Prioritized: bugs > safety > performance > tests > docs. Nothing above the suggestion tier surfaced.

Generated by Android PR Reviewer for #12269 · 100.2 AIC · ⌖ 18.8 AIC · ⊞ 6.9K
Comment /review to run again

Comment thread src/native/clr/host/gc-bridge.cc
@jonathanpeppers
jonathanpeppers force-pushed the jonathanpeppers-bridge-thread-uclamp-fix branch from 99c2c0c to 0f47780 Compare July 30, 2026 14:56
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
@jonathanpeppers
jonathanpeppers force-pushed the jonathanpeppers-bridge-thread-uclamp-fix branch from 0f47780 to 26dffcf Compare July 30, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant