[CoreCLR] Boost GC bridge thread CPU frequency via ADPF - #12269
[CoreCLR] Boost GC bridge thread CPU frequency via ADPF#12269jonathanpeppers wants to merge 3 commits into
Conversation
|
/review |
|
✅ Android PR Reviewer completed successfully! |
There was a problem hiding this comment.
🤖 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_enabledfield is appended to the CoreCLRApplicationConfigstruct, mirrored inApplicationConfigCLR.cs, andApplicationConfigFieldCount_CoreCLRwas bumped20 → 21with a matchingcase 20inEnvironmentHelper.cs. Field ordering is consistent across the native struct, the LLVM-IR generator, and the parser. - MSBuild plumbing (
_AndroidGCBridgeThreadBoostdefaulttrue→ taskGCBridgeThreadBoost→GCBridgeThreadBoostEnabled) 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
_AndroidGCBridgeThreadBoosttoggle 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
99c2c0c to
0f47780
Compare
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
0f47780 to
26dffcf
Compare
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
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'sschedutilDVFS 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)
APerformanceHintsession 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'sCONFIG_UCLAMP_TASKsetting — unlike a directsched_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:
src/native/clr/host/gc-bridge.cc: create the session once at bridge-thread start, then timeprocess()and callAPerformanceHint_reportActualWorkDurationeach round.reportActualWorkDurationonly 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'sAPerformanceHint_notifyWorkloadSpikeis 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 beforeRuntime.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).APerformanceHint_*API is API 33+ (Android 13). Newer symbols are weak-linked (__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__) and every call is guarded by a runtimeandroid_get_device_api_level()/ null check — the native equivalent of aBuild.VERSION.SDK_INTcheck._AndroidGCBridgeThreadBoost(default true), plumbed through to the CoreCLRApplicationConfigasgc_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_frequencyftrace +dalvikatrace, ~17 GC events pooled per CoreCLR arm). GC wall-time is ART's reportedtotalfor that collection (logcat, ~26 events pooled per CoreCLR arm over 2×90 s runs).Reading the numbers:
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— addedAPerformanceHint_notifyWorkloadSpikepre-boost (see Fix); it raised the carrier clock to Mono parity and shaved a further ~1 ms off the median.Caveats
dumpsys performance_hintreportsHAL 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 viauclampor RT priority, which are likewise platform-disabled. ThenotifyWorkloadSpikepre-boost additionally requires API 36+; on API 33–35 the report-only boost still applies.NativeAOT compatibility
src/native/clr/host/gc-bridge.ccis compiled into both the CoreCLR host (libnet-android) and the NativeAOT host (libnaot-android). NativeAOT does not link the generatedapplication_configsymbol (it has its own host and never pulls in the CoreCLR config), so referencingapplication_config.gc_bridge_thread_boost_enabledunconditionally broke every NativeAOT app link (ld: error: undefined symbol: application_config). UnderXA_HOST_NATIVEAOTthe boost is now enabled unconditionally (still fails soft) and theapplication_configread is compiled out, so the shared source links cleanly. Verified locally by rebuilding bothlibnet-android.release.soandlibnaot-android.release.soforandroid-arm64(both link with-Wl,--no-undefined; the weak ADPF symbols, includingnotifyWorkloadSpike, do not trip it).