From 26dffcf8b53a82795de65bc69309f60e981a663d Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 30 Jul 2026 11:12:21 -0500 Subject: [PATCH 1/3] [CoreCLR] Boost GC bridge thread CPU frequency via ADPF 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 dotnet/android#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 --- ...soft.Android.Sdk.DefaultProperties.targets | 4 + .../GenerateNativeApplicationConfigSources.cs | 2 + ...rateNativeApplicationConfigSourcesTests.cs | 32 +++++ .../Utilities/EnvironmentHelper.cs | 8 +- .../Utilities/ApplicationConfigCLR.cs | 1 + ...icationConfigNativeAssemblyGeneratorCLR.cs | 2 + .../Xamarin.Android.Common.targets | 1 + src/native/clr/host/gc-bridge.cc | 115 +++++++++++++++++- src/native/clr/include/xamarin-app.hh | 1 + 9 files changed, 164 insertions(+), 2 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.DefaultProperties.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.DefaultProperties.targets index 27d8a47aae4..5435e46915c 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.DefaultProperties.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.DefaultProperties.targets @@ -45,6 +45,10 @@ XAJavaInterop1 <_AndroidJcwCodegenTarget Condition=" '$(_AndroidJcwCodegenTarget)' == '' and '$(_AndroidRuntime)' != 'NativeAOT' ">XAJavaInterop1 <_AndroidTypeMapImplementation Condition=" '$(_AndroidTypeMapImplementation)' == '' and '$(_AndroidRuntime)' != 'NativeAOT' ">llvm-ir + + <_AndroidGCBridgeThreadBoost Condition=" '$(_AndroidGCBridgeThreadBoost)' == '' ">true true true true diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs index 17a4748d6fa..87e006c9cf3 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs @@ -59,6 +59,7 @@ public class GenerateNativeApplicationConfigSources : AndroidTask public bool EnableMarshalMethods { get; set; } public bool AndroidEnableAssemblyStoreDecompressionCache { get; set; } + public bool GCBridgeThreadBoost { get; set; } = true; public string? RuntimeConfigBinFilePath { get; set; } public string ProjectRuntimeConfigFilePath { get; set; } = String.Empty; public string? BoundExceptionType { get; set; } @@ -286,6 +287,7 @@ static bool ShouldSkipAssembly (ITaskItem assembly) IgnoreSplitConfigs = ShouldIgnoreSplitConfigs (), HaveAssemblyStore = UseAssemblyStore, AssemblyStoreDecompressionCacheEnabled = AndroidEnableAssemblyStoreDecompressionCache, + GCBridgeThreadBoostEnabled = GCBridgeThreadBoost, }; } else { appConfigAsmGen = new ApplicationConfigNativeAssemblyGenerator (envBuilder.EnvironmentVariables, envBuilder.SystemProperties, Log) { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateNativeApplicationConfigSourcesTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateNativeApplicationConfigSourcesTests.cs index 04cd7982543..43901ebb9fc 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateNativeApplicationConfigSourcesTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateNativeApplicationConfigSourcesTests.cs @@ -75,4 +75,36 @@ public void AssemblyStoreDecompressionCacheSettingIsEmitted (bool enabled) var config = (EnvironmentHelper.ApplicationConfig_CoreCLR)EnvironmentHelper.ReadApplicationConfig (environmentFiles, AndroidRuntime.CoreCLR); Assert.AreEqual (enabled, config.assembly_store_decompression_cache_enabled); } + + [Test] + public void GCBridgeThreadBoostSettingIsEmitted () + { + string outputRoot = Path.Combine (Root, "temp", nameof (GCBridgeThreadBoostSettingIsEmitted)); + string monoAndroidPath = Path.Combine (TestEnvironment.MonoAndroidFrameworkDirectory, "Mono.Android.dll"); + FileAssert.Exists (monoAndroidPath); + + var task = new GenerateNativeApplicationConfigSources { + BuildEngine = new MockBuildEngine (TestContext.Out), + ResolvedAssemblies = [new TaskItem (monoAndroidPath)], + EnvironmentOutputDirectory = Path.Combine (outputRoot, "android"), + SupportedAbis = ["arm64-v8a"], + AndroidPackageName = "com.microsoft.android.gcbridgeboosttest", + EnablePreloadAssembliesDefault = false, + TargetsCLR = true, + AndroidRuntime = "CoreCLR", + // Defaults to true; set false to prove the toggle actually propagates and the field is not hard-coded. + GCBridgeThreadBoost = false, + }; + + Assert.IsTrue (task.Execute (), "GenerateNativeApplicationConfigSources should succeed."); + + var environmentFiles = EnvironmentHelper.GatherEnvironmentFiles ( + outputRoot, + "arm64-v8a", + required: true, + runtime: AndroidRuntime.CoreCLR + ); + var config = (EnvironmentHelper.ApplicationConfig_CoreCLR)EnvironmentHelper.ReadApplicationConfig (environmentFiles, AndroidRuntime.CoreCLR); + Assert.IsFalse (config.gc_bridge_thread_boost_enabled, "GCBridgeThreadBoost=false should emit gc_bridge_thread_boost_enabled=false."); + } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/EnvironmentHelper.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/EnvironmentHelper.cs index 23138661645..ee2b93df1bb 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/EnvironmentHelper.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Utilities/EnvironmentHelper.cs @@ -62,9 +62,10 @@ public sealed class ApplicationConfig_CoreCLR : IApplicationConfig public string android_package_name = String.Empty; public bool have_assembly_store; public bool assembly_store_decompression_cache_enabled; + public bool gc_bridge_thread_boost_enabled; } - const uint ApplicationConfigFieldCount_CoreCLR = 20; + const uint ApplicationConfigFieldCount_CoreCLR = 21; // This must be identical to the ApplicationConfig structure in src/native/mono/xamarin-app-stub/xamarin-app.hh public sealed class ApplicationConfig_MonoVM : IApplicationConfig @@ -406,6 +407,11 @@ static IApplicationConfig ReadApplicationConfig_CoreCLR (EnvironmentFile envFile AssertFieldType (envFile.Path, parser.SourceFilePath, ".byte", field [0], item.LineNumber); ret.assembly_store_decompression_cache_enabled = ConvertFieldToBool ("assembly_store_decompression_cache_enabled", envFile.Path, parser.SourceFilePath, item.LineNumber, field [1]); break; + + case 20: // gc_bridge_thread_boost_enabled: bool / .byte + AssertFieldType (envFile.Path, parser.SourceFilePath, ".byte", field [0], item.LineNumber); + ret.gc_bridge_thread_boost_enabled = ConvertFieldToBool ("gc_bridge_thread_boost_enabled", envFile.Path, parser.SourceFilePath, item.LineNumber, field [1]); + break; } fieldCount++; } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/ApplicationConfigCLR.cs b/src/Xamarin.Android.Build.Tasks/Utilities/ApplicationConfigCLR.cs index 23924aed6fc..1e169a0eba4 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/ApplicationConfigCLR.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/ApplicationConfigCLR.cs @@ -51,4 +51,5 @@ sealed class ApplicationConfigCLR public string android_package_name = String.Empty; public bool have_assembly_store; public bool assembly_store_decompression_cache_enabled; + public bool gc_bridge_thread_boost_enabled; } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/ApplicationConfigNativeAssemblyGeneratorCLR.cs b/src/Xamarin.Android.Build.Tasks/Utilities/ApplicationConfigNativeAssemblyGeneratorCLR.cs index 53a1354d5be..cc0b6b9fec7 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/ApplicationConfigNativeAssemblyGeneratorCLR.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/ApplicationConfigNativeAssemblyGeneratorCLR.cs @@ -198,6 +198,7 @@ sealed class DsoCacheState public bool IgnoreSplitConfigs { get; set; } public bool HaveAssemblyStore { get; set; } public bool AssemblyStoreDecompressionCacheEnabled { get; set; } + public bool GCBridgeThreadBoostEnabled { get; set; } public ApplicationConfigNativeAssemblyGeneratorCLR (IDictionary environmentVariables, IDictionary systemProperties, IDictionary? runtimeProperties, TaskLoggingHelper log) @@ -284,6 +285,7 @@ protected override void Construct (LlvmIrModule module) android_package_name = AndroidPackageName, have_assembly_store = HaveAssemblyStore, assembly_store_decompression_cache_enabled = AssemblyStoreDecompressionCacheEnabled, + gc_bridge_thread_boost_enabled = GCBridgeThreadBoostEnabled, }; application_config = new StructureInstance (applicationConfigStructureInfo, app_cfg); module.AddGlobalVariable ("application_config", application_config); diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 1e57801422e..68db012bef5 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -1814,6 +1814,7 @@ because xbuild doesn't support framework reference assemblies. RuntimeConfigBinFilePath="$(_BinaryRuntimeConfigPath)" UseAssemblyStore="$(_AndroidUseAssemblyStore)" AndroidEnableAssemblyStoreDecompressionCache="$(AndroidEnableAssemblyStoreDecompressionCache)" + GCBridgeThreadBoost="$(_AndroidGCBridgeThreadBoost)" EnableMarshalMethods="$(_AndroidUseMarshalMethods)" CustomBundleConfigFile="$(AndroidBundleConfigurationFile)" TargetsCLR="$(_AndroidUseCLR)" diff --git a/src/native/clr/host/gc-bridge.cc b/src/native/clr/host/gc-bridge.cc index b9dc2a06184..8b9e869297c 100644 --- a/src/native/clr/host/gc-bridge.cc +++ b/src/native/clr/host/gc-bridge.cc @@ -1,7 +1,18 @@ +// The ADPF APerformanceHint_* API is API level 33+, but this runtime targets a lower minSdk. Weak-link +// the newer symbols so the translation unit builds, and guard every call with a runtime +// android_get_device_api_level() check (the native equivalent of Build.VERSION.SDK_INT >= 33). This +// define must precede any NDK header so __INTRODUCED_IN() emits weak references here. +#define __ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__ + #include #include +#include +#include #include #include +#include +#include +#include #include #include @@ -9,9 +20,90 @@ #include #include #include +#include using namespace xamarin::android; +namespace { + // 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 big core near the bottom of the frequency table for the whole short + // GC burst, making the GC ~2x slower than under MonoVM (which drives the same GC from the + // always-hot render thread). See dotnet/android#12263 / dotnet/runtime#131370. + // + // The Android-recommended way to raise the frequency for a latency-critical thread is ADPF (the + // Android Dynamic Performance Framework): create an APerformanceHint session for the thread with + // a target work duration and report the actual duration each round. The framework then boosts the + // carrier core (via uclamp or schedtune, whichever the kernel/power-HAL supports) whenever the + // thread runs. This works without root, without RT priority, and independent of the kernel's + // CONFIG_UCLAMP_TASK setting, unlike a direct sched_setattr() util-clamp hint. Everything here + // fails soft: a scheduling hint must never take down the process. + // + // Android docs: + // 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 + + // Target work duration reported to ADPF. The bridge GC should finish well inside a 60 Hz frame; + // a tight target relative to the real (multi-ms) duration keeps the framework boosting the core. + constexpr int64_t GCBridgeHintTargetDurationNs = 4'000'000; // 4 ms + + APerformanceHintSession *gc_bridge_hint_session = nullptr; + + auto monotonic_now_ns () noexcept -> int64_t + { + struct timespec ts; + clock_gettime (CLOCK_MONOTONIC, &ts); + return (static_cast (ts.tv_sec) * 1'000'000'000) + static_cast (ts.tv_nsec); + } + + // Create an ADPF performance-hint session bound to the calling (GC bridge) thread so the platform + // clocks its core up during the explicit GC. Must be called once, from the bridge thread itself. + void create_gc_bridge_hint_session () noexcept + { + // APerformanceHint_* was introduced in Android 13 (API 33 / Tiramisu). + if (android_get_device_api_level () < __ANDROID_API_T__) { + log_info (LOG_DEFAULT, "GC bridge boost: ADPF hint sessions require Android 13 (API 33); skipping"); + return; + } + + // The ADPF symbols are weak-linked (see the file header). The NativeAOT host does not link + // libandroid, so on that runtime the weak symbol can stay unresolved (null) even on API 33+. + // Check the function pointer itself before calling it, so an unresolved symbol fails soft + // instead of crashing the process on a null call. + if (APerformanceHint_getManager == nullptr) { + log_info (LOG_DEFAULT, "GC bridge boost: ADPF APerformanceHint API not available on this build; skipping"); + return; + } + + APerformanceHintManager *manager = APerformanceHint_getManager (); + if (manager == nullptr) { + log_info (LOG_DEFAULT, "GC bridge boost: no ADPF hint manager on this device; skipping"); + return; + } + + int32_t tids[1] = { static_cast (gettid ()) }; + APerformanceHintSession *session = APerformanceHint_createSession (manager, tids, 1, GCBridgeHintTargetDurationNs); + if (session == nullptr) { + log_info (LOG_DEFAULT, "GC bridge boost: device declined ADPF hint session; skipping"); + return; + } + + // Intentionally never APerformanceHint_closeSession()-d: the session lives for the lifetime of + // the GC bridge thread, which itself lives for the whole process. There is no teardown path to + // close it from, so this is a deliberate process-lifetime retention, not a leak. + gc_bridge_hint_session = session; + log_infof (LOG_DEFAULT, "GC bridge boost: ADPF hint session created (target=%" PRId64 " ns)", GCBridgeHintTargetDurationNs); + } + + // Report the just-finished bridge GC duration so ADPF keeps boosting this thread's core. Only ever + // called when a session exists, which implies the device is API 33+. + void report_gc_bridge_work (int64_t actual_duration_ns) noexcept + { + APerformanceHint_reportActualWorkDuration (gc_bridge_hint_session, actual_duration_ns); + } +} + void GCBridge::initialize_shared_args_semaphore () noexcept { int ret = sem_init (&shared_args_semaphore, 0, 0); @@ -110,7 +202,15 @@ void GCBridge::bridge_processing () noexcept bridge_processing_started_callback (args); BridgeProcessing bridge_processing {args}; - bridge_processing.process (); + if (gc_bridge_hint_session != nullptr) [[likely]] { + // Time the explicit bridge GC and report it to ADPF so the platform keeps this thread's + // carrier core clocked up for the rare, latency-sensitive collection. + int64_t start_ns = monotonic_now_ns (); + bridge_processing.process (); + report_gc_bridge_work (monotonic_now_ns () - start_ns); + } else { + bridge_processing.process (); + } bridge_processing_finished_callback (args); } @@ -118,6 +218,19 @@ void GCBridge::bridge_processing () noexcept auto GCBridge::bridge_processing_thread_entry ([[maybe_unused]] void *arg) noexcept -> void* { + // Ask the platform to boost this thread's CPU frequency for the explicit ART GC, so schedutil + // does not leave the mostly-idle bridge thread's core near the bottom of the frequency table. +#if defined (XA_HOST_NATIVEAOT) + // The NativeAOT host does not link the generated application_config symbol (it has its own host and + // never pulls in the CoreCLR config), so the per-app toggle is unavailable here. Enable the boost + // unconditionally; it still fails soft on devices without ADPF support. + create_gc_bridge_hint_session (); +#else + if (application_config.gc_bridge_thread_boost_enabled) { + create_gc_bridge_hint_session (); + } +#endif + bridge_processing (); return nullptr; } diff --git a/src/native/clr/include/xamarin-app.hh b/src/native/clr/include/xamarin-app.hh index 1fdb13b78a4..48d25dfce06 100644 --- a/src/native/clr/include/xamarin-app.hh +++ b/src/native/clr/include/xamarin-app.hh @@ -225,6 +225,7 @@ struct ApplicationConfig const char *android_package_name; bool have_assembly_store; bool assembly_store_decompression_cache_enabled; + bool gc_bridge_thread_boost_enabled; }; struct DSOCacheEntry From 7008ef321380f315ae187aab477b55b9f3b91285 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 30 Jul 2026 15:29:30 -0500 Subject: [PATCH 2/3] [CoreCLR] Pre-boost the GC bridge core with an ADPF workload-spike hint 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 --- src/native/clr/host/gc-bridge.cc | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/native/clr/host/gc-bridge.cc b/src/native/clr/host/gc-bridge.cc index 8b9e869297c..f40af5f52f2 100644 --- a/src/native/clr/host/gc-bridge.cc +++ b/src/native/clr/host/gc-bridge.cc @@ -24,6 +24,15 @@ using namespace xamarin::android; +// APerformanceHint_notifyWorkloadSpike() ships in Android 16 (API 36), newer than the NDK headers +// this builds against, so declare it here as a weak reference. libandroid resolves it at load time +// on API 36+; on older platforms it stays null, and it is likewise null under the NativeAOT host +// (which does not link libandroid). Being weak, it does not trip the linker's --no-undefined, exactly +// like the ADPF symbols weak-linked via __ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__. Its ABI is frozen +// (see the NDK header's stable-API notice), so this local declaration is safe. +extern "C" int APerformanceHint_notifyWorkloadSpike ( + APerformanceHintSession *session, bool cpu, bool gpu, const char *debugName) __attribute__((weak)); + namespace { // 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 @@ -50,6 +59,21 @@ namespace { APerformanceHintSession *gc_bridge_hint_session = nullptr; + // The bridge GC is a rare, one-off CPU spike on an otherwise-idle thread, so reporting its + // duration *after* the fact (via reportActualWorkDuration below) is too late to speed up that same + // collection: the DVFS governor takes ~200ms to ramp, far longer than the ~10ms GC. ADPF's + // APerformanceHint_notifyWorkloadSpike() exists for exactly this case -- it tells the framework a + // sudden spike is imminent so it pre-boosts the core *before* the work starts. We call it just + // before each Runtime.gc(). Rate-limited per app by the framework, but the bridge fires at most + // once every several seconds, well within budget. + // notifyWorkloadSpike (NDK): https://developer.android.com/ndk/reference/group/a-performance-hint + + // notifyWorkloadSpike was added in Android 16 (API 36), newer than the NDK this builds against, so + // it is not declared in and there is no __ANDROID_API_*__ macro for it + // to gate on. Instead we weak-link it (below) and null-check at the call site: libandroid only + // exports the symbol on API 36+, so a non-null pointer *is* the availability test -- more precise + // than an OS-version proxy. + auto monotonic_now_ns () noexcept -> int64_t { struct timespec ts; @@ -94,6 +118,11 @@ namespace { // close it from, so this is a deliberate process-lifetime retention, not a leak. gc_bridge_hint_session = session; log_infof (LOG_DEFAULT, "GC bridge boost: ADPF hint session created (target=%" PRId64 " ns)", GCBridgeHintTargetDurationNs); + + // Log once whether the Android 16 (API 36) spike pre-boost resolved on this platform; the + // per-GC path null-checks the weak symbol directly. + log_infof (LOG_DEFAULT, "GC bridge boost: workload-spike pre-boost %s", + APerformanceHint_notifyWorkloadSpike != nullptr ? "available" : "unavailable"); } // Report the just-finished bridge GC duration so ADPF keeps boosting this thread's core. Only ever @@ -203,6 +232,13 @@ void GCBridge::bridge_processing () noexcept BridgeProcessing bridge_processing {args}; if (gc_bridge_hint_session != nullptr) [[likely]] { + // Tell ADPF a CPU workload spike is imminent so it pre-ramps this thread's core *before* + // the GC starts, instead of the governor lagging ~200ms behind the ~10ms burst. The weak + // symbol is null (a no-op) on platforms without the API; see its declaration above. + if (APerformanceHint_notifyWorkloadSpike != nullptr) { + APerformanceHint_notifyWorkloadSpike (gc_bridge_hint_session, /* cpu */ true, /* gpu */ false, "gc-bridge"); + } + // Time the explicit bridge GC and report it to ADPF so the platform keeps this thread's // carrier core clocked up for the rare, latency-sensitive collection. int64_t start_ns = monotonic_now_ns (); From 01d1410df7afe14b6c51017352d216b6ae432be5 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 30 Jul 2026 16:49:42 -0500 Subject: [PATCH 3/3] [CoreCLR] Resolve ADPF hint API via dlopen/dlsym (enables NativeAOT) 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 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 --- src/native/clr/host/gc-bridge.cc | 129 ++++++++++++++++--------------- 1 file changed, 67 insertions(+), 62 deletions(-) diff --git a/src/native/clr/host/gc-bridge.cc b/src/native/clr/host/gc-bridge.cc index f40af5f52f2..4c7ce282bf9 100644 --- a/src/native/clr/host/gc-bridge.cc +++ b/src/native/clr/host/gc-bridge.cc @@ -1,18 +1,12 @@ -// The ADPF APerformanceHint_* API is API level 33+, but this runtime targets a lower minSdk. Weak-link -// the newer symbols so the translation unit builds, and guard every call with a runtime -// android_get_device_api_level() check (the native equivalent of Build.VERSION.SDK_INT >= 33). This -// define must precede any NDK header so __INTRODUCED_IN() emits weak references here. -#define __ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__ - #include #include +#include #include #include +#include #include #include #include -#include -#include #include #include @@ -24,15 +18,6 @@ using namespace xamarin::android; -// APerformanceHint_notifyWorkloadSpike() ships in Android 16 (API 36), newer than the NDK headers -// this builds against, so declare it here as a weak reference. libandroid resolves it at load time -// on API 36+; on older platforms it stays null, and it is likewise null under the NativeAOT host -// (which does not link libandroid). Being weak, it does not trip the linker's --no-undefined, exactly -// like the ADPF symbols weak-linked via __ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__. Its ABI is frozen -// (see the NDK header's stable-API notice), so this local declaration is safe. -extern "C" int APerformanceHint_notifyWorkloadSpike ( - APerformanceHintSession *session, bool cpu, bool gpu, const char *debugName) __attribute__((weak)); - namespace { // 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 @@ -40,39 +25,48 @@ namespace { // GC burst, making the GC ~2x slower than under MonoVM (which drives the same GC from the // always-hot render thread). See dotnet/android#12263 / dotnet/runtime#131370. // - // The Android-recommended way to raise the frequency for a latency-critical thread is ADPF (the - // Android Dynamic Performance Framework): create an APerformanceHint session for the thread with - // a target work duration and report the actual duration each round. The framework then boosts the - // carrier core (via uclamp or schedtune, whichever the kernel/power-HAL supports) whenever the - // thread runs. This works without root, without RT priority, and independent of the kernel's - // CONFIG_UCLAMP_TASK setting, unlike a direct sched_setattr() util-clamp hint. Everything here - // fails soft: a scheduling hint must never take down the process. + // The Android-recommended fix is ADPF (the Android Dynamic Performance Framework): create an + // APerformanceHint session bound to the bridge thread with a target work duration, report the + // actual duration each round, and notify the framework of the imminent spike so it pre-boosts the + // carrier core. The framework then clocks the core up (via uclamp or schedtune, whichever the + // kernel/power-HAL supports) whenever the thread runs. This works without root, without RT + // priority, and independent of the kernel's CONFIG_UCLAMP_TASK setting, unlike a direct + // sched_setattr() util-clamp hint. Everything here fails soft: a scheduling hint must never take + // down the process. + // + // The APerformanceHint_* entry points live in libandroid, which neither host links directly, and + // they are API-gated (getManager/createSession/reportActualWorkDuration are API 33+; + // notifyWorkloadSpike is API 36+). We resolve them at runtime with dlopen/dlsym rather than + // hard-linking libandroid and weak-linking the symbols: that keeps the dependency optional, avoids + // forcing libandroid into every app's DT_NEEDED (which under NativeAOT would also mean + // redistributing an NDK stub), behaves identically on the CoreCLR and NativeAOT hosts, and makes a + // missing library or symbol a natural no-op. A non-null resolved pointer *is* the availability + // test -- more precise than an OS-version proxy. // // Android docs: // 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 + + // Opaque ADPF handles. We deliberately do not include : its + // __INTRODUCED_IN declarations would emit references the linker would have to satisfy, defeating + // the point of resolving everything through dlsym. + struct APerformanceHintManager; + struct APerformanceHintSession; + + using APerformanceHint_getManager_fn = APerformanceHintManager* (*) (); + using APerformanceHint_createSession_fn = + APerformanceHintSession* (*) (APerformanceHintManager*, const int32_t*, size_t, int64_t); + using APerformanceHint_reportActualWorkDuration_fn = int (*) (APerformanceHintSession*, int64_t); + using APerformanceHint_notifyWorkloadSpike_fn = int (*) (APerformanceHintSession*, bool, bool, const char*); // Target work duration reported to ADPF. The bridge GC should finish well inside a 60 Hz frame; // a tight target relative to the real (multi-ms) duration keeps the framework boosting the core. constexpr int64_t GCBridgeHintTargetDurationNs = 4'000'000; // 4 ms + // Resolved once, on the bridge thread, in create_gc_bridge_hint_session(); read on the per-GC path. APerformanceHintSession *gc_bridge_hint_session = nullptr; - - // The bridge GC is a rare, one-off CPU spike on an otherwise-idle thread, so reporting its - // duration *after* the fact (via reportActualWorkDuration below) is too late to speed up that same - // collection: the DVFS governor takes ~200ms to ramp, far longer than the ~10ms GC. ADPF's - // APerformanceHint_notifyWorkloadSpike() exists for exactly this case -- it tells the framework a - // sudden spike is imminent so it pre-boosts the core *before* the work starts. We call it just - // before each Runtime.gc(). Rate-limited per app by the framework, but the bridge fires at most - // once every several seconds, well within budget. - // notifyWorkloadSpike (NDK): https://developer.android.com/ndk/reference/group/a-performance-hint - - // notifyWorkloadSpike was added in Android 16 (API 36), newer than the NDK this builds against, so - // it is not declared in and there is no __ANDROID_API_*__ macro for it - // to gate on. Instead we weak-link it (below) and null-check at the call site: libandroid only - // exports the symbol on API 36+, so a non-null pointer *is* the availability test -- more precise - // than an OS-version proxy. + APerformanceHint_reportActualWorkDuration_fn gc_bridge_report_actual_work_duration = nullptr; + APerformanceHint_notifyWorkloadSpike_fn gc_bridge_notify_workload_spike = nullptr; auto monotonic_now_ns () noexcept -> int64_t { @@ -85,29 +79,35 @@ namespace { // clocks its core up during the explicit GC. Must be called once, from the bridge thread itself. void create_gc_bridge_hint_session () noexcept { - // APerformanceHint_* was introduced in Android 13 (API 33 / Tiramisu). - if (android_get_device_api_level () < __ANDROID_API_T__) { - log_info (LOG_DEFAULT, "GC bridge boost: ADPF hint sessions require Android 13 (API 33); skipping"); + // libandroid is a core system library present on every device; when it is already mapped into + // the process (the common case) this dlopen just bumps its refcount. RTLD_LOCAL: we only dlsym + // our own handle. Kept open for the process lifetime -- there is no teardown path (see the + // session retention note below). + void *libandroid = ::dlopen ("libandroid.so", RTLD_NOW | RTLD_LOCAL); + if (libandroid == nullptr) { + log_info (LOG_DEFAULT, "GC bridge boost: libandroid.so not available; skipping ADPF hint"); return; } - // The ADPF symbols are weak-linked (see the file header). The NativeAOT host does not link - // libandroid, so on that runtime the weak symbol can stay unresolved (null) even on API 33+. - // Check the function pointer itself before calling it, so an unresolved symbol fails soft - // instead of crashing the process on a null call. - if (APerformanceHint_getManager == nullptr) { - log_info (LOG_DEFAULT, "GC bridge boost: ADPF APerformanceHint API not available on this build; skipping"); + // A non-null getManager is the availability test: libandroid only exports the APerformanceHint_* + // API on Android 13+ (API 33), so resolving these is equivalent to an SDK_INT >= 33 check, and + // works uniformly on the CoreCLR and NativeAOT hosts without either linking libandroid. + auto get_manager = reinterpret_cast (::dlsym (libandroid, "APerformanceHint_getManager")); + auto create_session = reinterpret_cast (::dlsym (libandroid, "APerformanceHint_createSession")); + auto report_actual = reinterpret_cast (::dlsym (libandroid, "APerformanceHint_reportActualWorkDuration")); + if (get_manager == nullptr || create_session == nullptr || report_actual == nullptr) { + log_info (LOG_DEFAULT, "GC bridge boost: ADPF hint sessions require Android 13 (API 33); skipping"); return; } - APerformanceHintManager *manager = APerformanceHint_getManager (); + APerformanceHintManager *manager = get_manager (); if (manager == nullptr) { log_info (LOG_DEFAULT, "GC bridge boost: no ADPF hint manager on this device; skipping"); return; } int32_t tids[1] = { static_cast (gettid ()) }; - APerformanceHintSession *session = APerformanceHint_createSession (manager, tids, 1, GCBridgeHintTargetDurationNs); + APerformanceHintSession *session = create_session (manager, tids, 1, GCBridgeHintTargetDurationNs); if (session == nullptr) { log_info (LOG_DEFAULT, "GC bridge boost: device declined ADPF hint session; skipping"); return; @@ -117,19 +117,23 @@ namespace { // the GC bridge thread, which itself lives for the whole process. There is no teardown path to // close it from, so this is a deliberate process-lifetime retention, not a leak. gc_bridge_hint_session = session; - log_infof (LOG_DEFAULT, "GC bridge boost: ADPF hint session created (target=%" PRId64 " ns)", GCBridgeHintTargetDurationNs); + gc_bridge_report_actual_work_duration = report_actual; - // Log once whether the Android 16 (API 36) spike pre-boost resolved on this platform; the - // per-GC path null-checks the weak symbol directly. + // notifyWorkloadSpike was added in Android 16 (API 36); it stays null on older platforms, which + // disables only the pre-boost -- the after-the-fact reportActualWorkDuration path still runs. + gc_bridge_notify_workload_spike = + reinterpret_cast (::dlsym (libandroid, "APerformanceHint_notifyWorkloadSpike")); + + log_infof (LOG_DEFAULT, "GC bridge boost: ADPF hint session created (target=%" PRId64 " ns)", GCBridgeHintTargetDurationNs); log_infof (LOG_DEFAULT, "GC bridge boost: workload-spike pre-boost %s", - APerformanceHint_notifyWorkloadSpike != nullptr ? "available" : "unavailable"); + gc_bridge_notify_workload_spike != nullptr ? "available" : "unavailable"); } // Report the just-finished bridge GC duration so ADPF keeps boosting this thread's core. Only ever - // called when a session exists, which implies the device is API 33+. + // called when a session exists, which guarantees the reporter pointer was resolved alongside it. void report_gc_bridge_work (int64_t actual_duration_ns) noexcept { - APerformanceHint_reportActualWorkDuration (gc_bridge_hint_session, actual_duration_ns); + gc_bridge_report_actual_work_duration (gc_bridge_hint_session, actual_duration_ns); } } @@ -233,10 +237,10 @@ void GCBridge::bridge_processing () noexcept BridgeProcessing bridge_processing {args}; if (gc_bridge_hint_session != nullptr) [[likely]] { // Tell ADPF a CPU workload spike is imminent so it pre-ramps this thread's core *before* - // the GC starts, instead of the governor lagging ~200ms behind the ~10ms burst. The weak - // symbol is null (a no-op) on platforms without the API; see its declaration above. - if (APerformanceHint_notifyWorkloadSpike != nullptr) { - APerformanceHint_notifyWorkloadSpike (gc_bridge_hint_session, /* cpu */ true, /* gpu */ false, "gc-bridge"); + // the GC starts, instead of the governor lagging ~200ms behind the ~10ms burst. Null (a + // no-op) on platforms below API 36; resolved in create_gc_bridge_hint_session(). + if (gc_bridge_notify_workload_spike != nullptr) { + gc_bridge_notify_workload_spike (gc_bridge_hint_session, /* cpu */ true, /* gpu */ false, "gc-bridge"); } // Time the explicit bridge GC and report it to ADPF so the platform keeps this thread's @@ -259,7 +263,8 @@ auto GCBridge::bridge_processing_thread_entry ([[maybe_unused]] void *arg) noexc #if defined (XA_HOST_NATIVEAOT) // The NativeAOT host does not link the generated application_config symbol (it has its own host and // never pulls in the CoreCLR config), so the per-app toggle is unavailable here. Enable the boost - // unconditionally; it still fails soft on devices without ADPF support. + // unconditionally; the ADPF entry points are resolved via dlopen/dlsym, so they now work on this + // host too, and it still fails soft on devices without ADPF support. create_gc_bridge_hint_session (); #else if (application_config.gc_bridge_thread_boost_enabled) {