From d2f07a11647a4d2dd63bfb08ed1585609bfd745f Mon Sep 17 00:00:00 2001 From: David Nguyen <87228593+davidnguyen-tech@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:34:45 +0200 Subject: [PATCH 1/3] [Build] Add per-file up-to-date check in CompileNativeAssembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When _CompileNativeAssemblySources runs, it recompiles ALL .ll files even if only some have changed. This is because MSBuild's target-level Inputs/Outputs is all-or-nothing — if any .ll file is newer than any .o file, every file gets recompiled. Add a per-file timestamp check in RunAssembler() — if the output .o is newer than the input .ll, that file is skipped. This saves time on incremental CoreCLR builds where upstream generators use CopyIfStreamChanged to preserve .ll timestamps for unchanged files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/CompileNativeAssembly.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/CompileNativeAssembly.cs b/src/Xamarin.Android.Build.Tasks/Tasks/CompileNativeAssembly.cs index 49402865722..4b3952d8141 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/CompileNativeAssembly.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/CompileNativeAssembly.cs @@ -22,6 +22,7 @@ sealed class Config public string? AssemblerPath; public string? AssemblerOptions; public string? InputSource; + public string? OutputFile; } [Required] @@ -43,6 +44,14 @@ public override System.Threading.Tasks.Task RunTaskAsync () void RunAssembler (Config config) { + if (config.OutputFile is not null && config.InputSource is not null && File.Exists (config.OutputFile)) { + string sourceFile = Path.Combine (WorkingDirectory, Path.GetFileName (config.InputSource)); + if (File.Exists (sourceFile) && File.GetLastWriteTimeUtc (config.OutputFile) >= File.GetLastWriteTimeUtc (sourceFile)) { + LogDebugMessage ($"[LLVM llc] Skipping '{Path.GetFileName (config.InputSource)}' because '{Path.GetFileName (config.OutputFile)}' is up to date"); + return; + } + } + var stdout_completed = new ManualResetEvent (false); var stderr_completed = new ManualResetEvent (false); var psi = new ProcessStartInfo () { @@ -118,10 +127,13 @@ IEnumerable GetAssemblerConfigs () string executableDir = Path.GetDirectoryName (llcPath); string executableName = MonoAndroidHelper.GetExecutablePath (executableDir, Path.GetFileName (llcPath)); + string outputFilePath = Path.Combine (WorkingDirectory, sourceFile.Replace (".ll", ".o")); + yield return new Config { InputSource = item.ItemSpec, AssemblerPath = Path.Combine (executableDir, executableName), AssemblerOptions = $"{assemblerOptions} -o={outputFile} {QuoteFileName (sourceFile)}", + OutputFile = outputFilePath, }; } } From f644fcadbeb043c27d48ce49b5235cf312cf0643 Mon Sep 17 00:00:00 2001 From: David Nguyen <87228593+davidnguyen-tech@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:52:12 +0200 Subject: [PATCH 2/3] [Tests] Add test for CompileNativeAssembly per-file skip Add CompileNativeAssemblySourcesSkipsUnchangedFiles integration test that verifies on incremental CoreCLR builds, unchanged .ll files are not recompiled by the CompileNativeAssembly task while changed .ll files are still correctly compiled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../IncrementalBuildTest.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/IncrementalBuildTest.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/IncrementalBuildTest.cs index 8e3600f20dd..607a832afb9 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/IncrementalBuildTest.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/IncrementalBuildTest.cs @@ -1289,6 +1289,38 @@ public void GenerateJavaStubsAndAssembly ([Values] bool isRelease, [Values] Andr } } + [Test] + public void CompileNativeAssemblySourcesSkipsUnchangedFiles ([Values (AndroidRuntime.CoreCLR)] AndroidRuntime runtime) + { + if (IgnoreUnsupportedConfiguration (runtime, release: false)) { + return; + } + + var proj = new XamarinAndroidApplicationProject (); + proj.SetRuntime (runtime); + + string abi = "arm64-v8a"; + proj.SetRuntimeIdentifier (abi); + + using (var b = CreateApkBuilder ()) { + b.Verbosity = LoggerVerbosity.Detailed; + Assert.IsTrue (b.Build (proj), "first build should have succeeded."); + + // Modify MainActivity to trigger recompilation of typemap sources + proj.MainActivity = proj.DefaultMainActivity + Environment.NewLine + "// test comment"; + proj.Touch ("MainActivity.cs"); + Assert.IsTrue (b.Build (proj), "second build should have succeeded."); + + Assert.IsFalse (b.Output.IsTargetSkipped ("_CompileNativeAssemblySources"), "`_CompileNativeAssemblySources` should *not* be skipped!"); + + // At least one .ll file should have been skipped as up to date (e.g., environment.arm64-v8a.ll) + Assert.IsTrue ( + StringAssertEx.ContainsRegex (@"\[LLVM llc\] Skipping.*up to date", b.LastBuildOutput), + "Expected at least one .ll file to be skipped as up to date" + ); + } + } + readonly string [] ExpectedAssemblyFiles = new [] { Path.Combine ("android", "environment.@ABI@.o"), Path.Combine ("android", "environment.@ABI@.ll"), From 3911d08cd81a75d8cd69eaefe1651f76e69511a7 Mon Sep 17 00:00:00 2001 From: David Nguyen <87228593+davidnguyen-tech@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:19:35 +0200 Subject: [PATCH 3/3] [Build] Remove mvid_hash from typemap for stable incremental builds Remove build-specific MVID data from the CoreCLR debug typemap assemblies array, making the generated .ll files byte-identical across incremental builds when only C# code changes (no type additions/removals). Generator changes (TypeMappingDebugNativeAssemblyGeneratorCLR.cs): - Remove mvid_hash field from TypeMapAssembly struct - Remove MVID field (was already NativeAssembler-ignored) - Sort data.UniqueAssemblies by name before building the assembly names blob, ensuring deterministic blob offsets - Sort uniqueAssemblies list by Name instead of mvid_hash Runtime changes (typemap.cc, xamarin-app.hh): - Remove mvid_hash from the native TypeMapAssembly struct - Replace MVID-based binary search with assembly iteration: for each assembly, construct "TypeName, AssemblyName" and look up in the managed-to-java map. The array is small (~80-100 entries), so iteration cost is negligible vs the previous hash+binary-search. This eliminates the root cause of typemaps.*.ll content changes during C#-only incremental edits. Combined with the per-file skip from commit d2f07a116, this saves ~2.85s per CoreCLR incremental build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../IncrementalBuildTest.cs | 5 +-- ...eMappingDebugNativeAssemblyGeneratorCLR.cs | 20 ++++----- src/native/clr/host/typemap.cc | 41 ++++++++++--------- src/native/clr/include/xamarin-app.hh | 1 - 4 files changed, 32 insertions(+), 35 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/IncrementalBuildTest.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/IncrementalBuildTest.cs index 607a832afb9..7b30a0e4a79 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/IncrementalBuildTest.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/IncrementalBuildTest.cs @@ -1314,9 +1314,8 @@ public void CompileNativeAssemblySourcesSkipsUnchangedFiles ([Values (AndroidRun Assert.IsFalse (b.Output.IsTargetSkipped ("_CompileNativeAssemblySources"), "`_CompileNativeAssemblySources` should *not* be skipped!"); // At least one .ll file should have been skipped as up to date (e.g., environment.arm64-v8a.ll) - Assert.IsTrue ( - StringAssertEx.ContainsRegex (@"\[LLVM llc\] Skipping.*up to date", b.LastBuildOutput), - "Expected at least one .ll file to be skipped as up to date" + StringAssertEx.ContainsRegex (@"\[LLVM llc\] Skipping.*up to date", b.LastBuildOutput, + message: "Expected at least one .ll file to be skipped as up to date" ); } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/TypeMappingDebugNativeAssemblyGeneratorCLR.cs b/src/Xamarin.Android.Build.Tasks/Utilities/TypeMappingDebugNativeAssemblyGeneratorCLR.cs index 0bd20902776..2b5e61c968f 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/TypeMappingDebugNativeAssemblyGeneratorCLR.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/TypeMappingDebugNativeAssemblyGeneratorCLR.cs @@ -73,10 +73,6 @@ public override string GetComment (object data, string fieldName) { var entry = EnsureType (data); - if (MonoAndroidHelper.StringEquals ("mvid_hash", fieldName)) { - return $" MVID: {entry.MVID}"; - } - if (MonoAndroidHelper.StringEquals ("name_offset", fieldName)) { return $" {entry.Name}"; } @@ -171,11 +167,6 @@ sealed class TypeMapAssembly [NativeAssembler (Ignore = true)] public string Name = String.Empty; - [NativeAssembler (Ignore = true)] - public Guid MVID; - - [NativeAssembler (UsesDataProvider = true, NumberFormat = LlvmIrVariableNumberFormat.Hexadecimal)] - public ulong mvid_hash; public ulong name_length; [NativeAssembler (UsesDataProvider = true)] @@ -274,21 +265,26 @@ protected override void Construct (LlvmIrModule module) Log.LogMessage ("Managed-to-java typemaps will use string-based matching."); } + // Sort assemblies by name before building the blob so that both the blob offsets + // and the uniqueAssemblies array are in a deterministic order that is stable across + // incremental builds (assembly names don't change, unlike MVIDs). + data.UniqueAssemblies.Sort ((a, b) => StringComparer.Ordinal.Compare (a.Name, b.Name)); + var assemblyNamesBlob = new LlvmIrStringBlob (); foreach (TypeMapGenerator.TypeMapDebugAssembly asm in data.UniqueAssemblies) { (int assemblyNameOffset, int assemblyNameLength) = assemblyNamesBlob.Add (asm.Name); var entry = new TypeMapAssembly { Name = asm.Name, - MVID = asm.MVID, - mvid_hash = MonoAndroidHelper.GetXxHash (asm.MVIDBytes, is64Bit: true), name_length = (ulong)assemblyNameLength, // without the trailing NUL name_offset = (ulong)assemblyNameOffset, }; uniqueAssemblies.Add (new StructureInstance (typeMapAssemblyStructureInfo, entry)); } + // Sort by assembly name for deterministic output. This ensures the .ll content + // is stable across incremental builds when only MVIDs change. uniqueAssemblies.Sort ((StructureInstance a, StructureInstance b) => { if (a.Instance == null) { return b.Instance == null ? 0 : -1; @@ -298,7 +294,7 @@ protected override void Construct (LlvmIrModule module) return 1; } - return a.Instance.mvid_hash.CompareTo (b.Instance.mvid_hash); + return StringComparer.Ordinal.Compare (a.Instance.Name, b.Instance.Name); }); var managedTypeInfos = new List> (); diff --git a/src/native/clr/host/typemap.cc b/src/native/clr/host/typemap.cc index 85e5a37a3a5..c402373d6c2 100644 --- a/src/native/clr/host/typemap.cc +++ b/src/native/clr/host/typemap.cc @@ -141,30 +141,33 @@ auto TypeMapper::index_to_name (ssize_t idx, const char* typeName, const TypeMap } [[gnu::always_inline, gnu::flatten]] -auto TypeMapper::managed_to_java_debug (const char *typeName, const uint8_t *mvid) noexcept -> const char* +auto TypeMapper::managed_to_java_debug (const char *typeName, [[maybe_unused]] const uint8_t *mvid) noexcept -> const char* { - dynamic_local_path_string full_type_name; - full_type_name.append (typeName); - - hash_t mvid_hash = xxhash::hash (mvid, 16z); // we must hope managed land called us with valid data - - auto equal = [](TypeMapAssembly const& entry, hash_t key) -> bool { return entry.mvid_hash == key; }; - auto less_than = [](TypeMapAssembly const& entry, hash_t key) -> bool { return entry.mvid_hash < key; }; - ssize_t idx = Search::binary_search (mvid_hash, type_map_unique_assemblies, type_map.unique_assemblies_count); - - if (idx >= 0) [[likely]] { - TypeMapAssembly const& assm = type_map_unique_assemblies[idx]; + // type_map_unique_assemblies is sorted by assembly name for stable build output (no + // build-specific data like MVIDs). We iterate through assemblies to find which one + // contains this type by trying each "TypeName, AssemblyName" candidate against the + // managed-to-java map. The array is small (~80-100 entries), so this is negligible. + for (size_t i = 0; i < type_map.unique_assemblies_count; i++) { + TypeMapAssembly const& assm = type_map_unique_assemblies[i]; + + dynamic_local_path_string full_type_name; + full_type_name.append (typeName); full_type_name.append (", "sv); - - // We explicitly trust the build process here, with regards to validity of offsets full_type_name.append (&type_map_assembly_names[assm.name_offset], assm.name_length); - } else { - log_warn (LOG_ASSEMBLY, "typemap: unable to look up assembly name for type '{}', trying without it."sv, typeName); + + ssize_t idx = find_index_by_hash (full_type_name.get (), type_map.managed_to_java, type_map_managed_type_names, MANAGED, JAVA); + if (idx >= 0) { + return index_to_name (idx, full_type_name.get (), type_map.managed_to_java, type_map_java_type_names, MANAGED, JAVA); + } } - // If hashes are used for matching, the type names array is not used. If, however, string-based matching is in - // effect, the managed type name is looked up and then... - idx = find_index_by_hash (full_type_name.get (), type_map.managed_to_java, type_map_managed_type_names, MANAGED, JAVA); + // Fallback: try without assembly name + dynamic_local_path_string full_type_name; + full_type_name.append (typeName); + + log_warn (LOG_ASSEMBLY, "typemap: unable to look up assembly name for type '{}', trying without it."sv, typeName); + + ssize_t idx = find_index_by_hash (full_type_name.get (), type_map.managed_to_java, type_map_managed_type_names, MANAGED, JAVA); // ...either method gives us index into the Java type names array return index_to_name (idx, full_type_name.get (), type_map.managed_to_java, type_map_java_type_names, MANAGED, JAVA); diff --git a/src/native/clr/include/xamarin-app.hh b/src/native/clr/include/xamarin-app.hh index c2140e2c882..b7cc9485cc7 100644 --- a/src/native/clr/include/xamarin-app.hh +++ b/src/native/clr/include/xamarin-app.hh @@ -69,7 +69,6 @@ struct TypeMap // MUST match src/Xamarin.Android.Build.Tasks/Utilities/TypeMappingDebugNativeAssemblyGeneratorCLR.cs struct TypeMapAssembly { - xamarin::android::hash_t mvid_hash; uint64_t name_length; uint64_t name_offset; // into the assembly names blob };