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