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..4c7ce282bf9 100644 --- a/src/native/clr/host/gc-bridge.cc +++ b/src/native/clr/host/gc-bridge.cc @@ -1,7 +1,12 @@ #include #include +#include +#include +#include +#include #include #include +#include #include #include @@ -9,9 +14,129 @@ #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 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 + + // 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; + 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 + { + 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 + { + // 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; + } + + // 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 = 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 = create_session (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; + gc_bridge_report_actual_work_duration = report_actual; + + // 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", + 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 guarantees the reporter pointer was resolved alongside it. + void report_gc_bridge_work (int64_t actual_duration_ns) noexcept + { + gc_bridge_report_actual_work_duration (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 +235,22 @@ void GCBridge::bridge_processing () noexcept bridge_processing_started_callback (args); BridgeProcessing bridge_processing {args}; - bridge_processing.process (); + 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. 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 + // 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 +258,20 @@ 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; 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) { + 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