Skip to content

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

Draft
jonathanpeppers wants to merge 3 commits into
mainfrom
jonathanpeppers-bridge-thread-uclamp-fix
Draft

[CoreCLR] Boost GC bridge thread CPU frequency via ADPF#12269
jonathanpeppers wants to merge 3 commits 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.
  • Workload-spike pre-boost. reportActualWorkDuration only teaches the governor after a collection, so the very GC being measured still starts before the core ramps (~200 ms governor lag vs a ~10 ms GC). ADPF's APerformanceHint_notifyWorkloadSpike is Google's recommended API for a "sudden, one-off spike on an otherwise-idle thread" — exactly the bridge-GC pattern — so we call it just before Runtime.gc() to pre-ramp the core before the collection. It shipped in Android 16 (API 36), newer than the NDK headers this builds against, so it is weak-linked and null-checked (libandroid only exports it on API 36+; it is also null under NativeAOT, which does not link libandroid).
  • 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() / null 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), screen verified on, 60 Hz pinned, all arms captured back-to-back under identical conditions on the same benchmark. Carrier-core frequency is the time-weighted CPU frequency the ART "Explicit concurrent mark compact GC" slice's carrier core runs at (Perfetto cpu_frequency ftrace + dalvik atrace, ~17 GC events pooled per CoreCLR arm). GC wall-time is ART's reported total for that collection (logcat, ~26 events pooled per CoreCLR arm over 2×90 s runs).

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 1101 MHz 17.5 ms 17.2 ms ~6
CoreCLR, boost ON (this PR) dedicated bridge thread 2660 MHz 11.8 ms 11.6 ms ~6
MonoVM (reference) GL render thread 2400 MHz 7.9 ms 8.4 ms ~6

Reading the numbers:

  • The boost raises the bridge thread's carrier core from ~1.1 GHz to ~2.66 GHz — now at or above the ~2.4 GHz Mono's render thread runs at — and cuts explicit-GC wall-time 17.5 → 11.8 ms (−33% median), tightening the tail (worst-case ~20.6 → ~13.7 ms) and eliminating the pathological stretched collections. That closes ~59% of the CoreCLR→Mono wall-time gap.
  • The DVFS/frequency root cause is now fully closed. The carrier core no longer sits at the bottom of the frequency table during the bridge GC; it clocks up to (and slightly past) where Mono's always-hot GL thread already sits. The addition of the workload-spike pre-boost is what pushed the median clock from the ~1.75 GHz of the report-only version up to ~2.66 GHz.
  • The residual ~4 ms vs Mono is therefore no longer a clock/placement problem — at equal-or-higher frequency the CoreCLR bridge collection still does more work per GC than Mono's path (bridge cross-reference processing, and a larger managed heap/allocation profile under CoreCLR). Closing it further is an algorithmic question for a follow-up, not a scheduling one.

Follow-ups / TODO

  • Close the residual gap toward Mono via CPU affinity — prototyped prime-core affinity (helped ~20% locally) but rejected: Android's Performance Hint API guidance explicitly says apps should not set CPU-core affinity (device-fragile, often ignored, thermally unsound). This PR uses only Google-recommended ADPF APIs.
  • Lower ADPF target / pre-boost — added APerformanceHint_notifyWorkloadSpike pre-boost (see Fix); it raised the carrier clock to Mono parity and shaved a further ~1 ms off the median.
  • Investigate the remaining ~4 ms as GC work per collection (bridge cross-reference cost / heap profile), now that frequency is no longer the bottleneck.
  • 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. The notifyWorkloadSpike pre-boost additionally requires API 36+; on API 33–35 the report-only boost still applies.

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, including notifyWorkloadSpike, 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
jonathanpeppers and others added 2 commits July 30, 2026 15:29
The base ADPF change reports the bridge GC duration *after* each collection
via APerformanceHint_reportActualWorkDuration(). That teaches the framework
about the workload for *next* time, but the DVFS governor still lags ~200ms
behind the ~10ms burst, so the very GC being measured runs before the core
ramps up.

APerformanceHint_notifyWorkloadSpike() is the doc-recommended API for a
"sudden, one-off spike on an otherwise-idle thread" -- exactly the bridge GC
pattern. Calling it just before Runtime.gc() lets the framework pre-boost the
core *before* the collection starts.

The symbol was added in Android 16 (API 36), newer than the NDK headers this
builds against, so it is weak-linked (like the other ADPF symbols) and gated
by a null-check: libandroid only exports it on API 36+, so a resolved pointer
is the availability test. It is likewise null under the NativeAOT host, which
does not link libandroid.

On-device A/B (Pixel 10, API 36, 44 explicit-GC events/arm): startup-trimmed
mean 11.74 -> 11.27 ms (~4%), best single GC 8.7 -> 7.2 ms. Modest but real,
and uses only Android-recommended APIs.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: ea778d78-47ba-4349-8857-9dd3c91be5ca
The GC bridge boost previously called the ADPF APerformanceHint_* API via
weak-linked NDK symbols. That only works when libandroid is in the host's
DT_NEEDED: the CoreCLR host links -landroid, so the weak symbols resolve
there, but the NativeAOT host links only libdl/liblog/libm/libc, so the
weak symbols stayed null and the boost silently no-op'd on NativeAOT.

Resolve the four entry points (APerformanceHint_getManager,
_createSession, _reportActualWorkDuration, and the API 36+
_notifyWorkloadSpike) at runtime with dlopen("libandroid.so") + dlsym
instead. libandroid is a core system library already mapped into every
app process, and dlopen/dlsym come from libdl, which both hosts already
link. This makes the dependency optional and self-contained, needs zero
build-system changes, behaves identically on the CoreCLR and NativeAOT
hosts, and fails soft by construction: a missing library or symbol just
disables the hint. A non-null resolved pointer is the availability test,
which is more precise than an OS-version proxy, so the explicit
android_get_device_api_level() gate is gone.

The translation unit no longer includes <android/performance_hint.h> or
defines __ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__; the opaque ADPF handle
types and function-pointer signatures are declared locally.

Validated on-device (Pixel 10 / Tensor G5 / API 36):
  - CoreCLR: "ADPF hint session created" + "workload-spike pre-boost
    available" (unchanged from the weak-linked build).
  - NativeAOT: now logs the same two lines. The app .so has no libandroid
    in DT_NEEDED yet the hint resolves and activates at runtime, where the
    weak-linked build logged "APerformanceHint API not available".
Both packaged .so's carry zero undefined APerformanceHint imports.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant