diff --git a/.gitignore b/.gitignore index 77b3f03dc0e..43f47c9e72a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ artifacts/ .dotnet/ .tools/ .packages/ +BenchmarkDotNet.Artifacts/ # Visual Studio 2015 cache/options directory .vs/ diff --git a/MSBuild.Dev.slnf b/MSBuild.Dev.slnf index b16cb4e7346..c19f4aca995 100644 --- a/MSBuild.Dev.slnf +++ b/MSBuild.Dev.slnf @@ -10,6 +10,7 @@ "src\\Framework\\Microsoft.Build.Framework.csproj", "src\\MSBuild.UnitTests\\Microsoft.Build.CommandLine.UnitTests.csproj", "src\\MSBuild\\MSBuild.csproj", + "src\\MSBuild.Benchmarks\\MSBuild.Benchmarks.csproj", "src\\StringTools\\StringTools.csproj", "src\\Tasks.UnitTests\\Microsoft.Build.Tasks.UnitTests.csproj", "src\\Tasks\\Microsoft.Build.Tasks.csproj", diff --git a/MSBuild.slnx b/MSBuild.slnx index 405c0641e55..a0d0af13a75 100644 --- a/MSBuild.slnx +++ b/MSBuild.slnx @@ -67,6 +67,10 @@ + + + + diff --git a/eng/dependabot/Directory.Packages.props b/eng/dependabot/Directory.Packages.props index fc8641a4ad9..a105a42d060 100644 --- a/eng/dependabot/Directory.Packages.props +++ b/eng/dependabot/Directory.Packages.props @@ -10,9 +10,12 @@ these properties to override package versions if necessary. --> - + + + + diff --git a/src/Build/BackEnd/BuildManager/BuildManager.cs b/src/Build/BackEnd/BuildManager/BuildManager.cs index 6cba67e231c..1cfaff635a1 100644 --- a/src/Build/BackEnd/BuildManager/BuildManager.cs +++ b/src/Build/BackEnd/BuildManager/BuildManager.cs @@ -1105,6 +1105,7 @@ public void EndBuild() } TaskRouter.ClearCache(); + ItemSpecModifiers.ClearDefiningProjectCache(); } catch (Exception e) { diff --git a/src/Build/Definition/BuiltInMetadata.cs b/src/Build/Definition/BuiltInMetadata.cs index b93845b4b3b..d8cf8babc57 100644 --- a/src/Build/Definition/BuiltInMetadata.cs +++ b/src/Build/Definition/BuiltInMetadata.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics; using Microsoft.Build.Framework; using Microsoft.Build.Shared; @@ -19,22 +19,12 @@ internal static class BuiltInMetadata /// /// Retrieves the count of built-in metadata. /// - internal static int MetadataCount - { - [DebuggerStepThrough] - get - { return ItemSpecModifiers.All.Length; } - } + internal static int MetadataCount => ItemSpecModifiers.All.Length; /// /// Retrieves the list of metadata names. /// - internal static ICollection MetadataNames - { - [DebuggerStepThrough] - get - { return ItemSpecModifiers.All; } - } + internal static ImmutableArray MetadataNames => ItemSpecModifiers.All; /// /// Retrieves a built-in metadata value and caches it. @@ -48,45 +38,53 @@ internal static ICollection MetadataNames /// The evaluated include for the item. /// The path to the project that defined this item /// The name of the metadata. - /// The generated full path, for caching + /// The generated full path, for caching /// The unescaped metadata value. - internal static string GetMetadataValue(string currentDirectory, string evaluatedIncludeBeforeWildcardExpansionEscaped, string evaluatedIncludeEscaped, string definingProjectEscaped, string name, ref string fullPath) - { - return EscapingUtilities.UnescapeAll(GetMetadataValueEscaped(currentDirectory, evaluatedIncludeBeforeWildcardExpansionEscaped, evaluatedIncludeEscaped, definingProjectEscaped, name, ref fullPath)); - } + internal static string GetMetadataValue( + string currentDirectory, + string evaluatedIncludeBeforeWildcardExpansionEscaped, + string evaluatedIncludeEscaped, + string definingProjectEscaped, + string name, + ref ItemSpecModifiers.Cache cache) + => EscapingUtilities.UnescapeAll(GetMetadataValueEscaped(currentDirectory, evaluatedIncludeBeforeWildcardExpansionEscaped, evaluatedIncludeEscaped, definingProjectEscaped, name, ref cache)); /// - /// Retrieves a built-in metadata value and caches it. + /// Retrieves a built-in metadata value, caching derivable results in the provided per-item cache. /// If value is not available, returns empty string. /// - /// - /// The current directory for evaluation. Null if this is being called from a task, otherwise - /// it should be the project's directory. - /// - /// The evaluated include prior to wildcard expansion. - /// The evaluated include for the item. - /// The path to the project that defined this item - /// The name of the metadata. - /// The generated full path, for caching - /// The escaped as necessary metadata value. - internal static string GetMetadataValueEscaped(string currentDirectory, string evaluatedIncludeBeforeWildcardExpansionEscaped, string evaluatedIncludeEscaped, string definingProjectEscaped, string name, ref string fullPath) + internal static string GetMetadataValueEscaped( + string currentDirectory, + string evaluatedIncludeBeforeWildcardExpansionEscaped, + string evaluatedIncludeEscaped, + string definingProjectEscaped, + string name, + ref ItemSpecModifiers.Cache cache) { - // This is an assert, not a VerifyThrow, because the caller should already have done this check, and it's slow/hot. - Debug.Assert(ItemSpecModifiers.IsItemSpecModifier(name)); - - string value; - if (String.Equals(name, ItemSpecModifiers.RecursiveDir, StringComparison.OrdinalIgnoreCase)) + if (ItemSpecModifiers.TryGetModifierKind(name, out ItemSpecModifierKind modifierKind)) { - value = GetRecursiveDirValue(evaluatedIncludeBeforeWildcardExpansionEscaped, evaluatedIncludeEscaped); - } - else - { - value = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, evaluatedIncludeEscaped, definingProjectEscaped, name, ref fullPath); + return GetMetadataValueEscaped(currentDirectory, evaluatedIncludeBeforeWildcardExpansionEscaped, evaluatedIncludeEscaped, definingProjectEscaped, modifierKind, ref cache); } - return value; + Debug.Fail($"Expected a valid item-spec modifier, got \"{name}\"."); + return string.Empty; } + /// + /// Retrieves a built-in metadata value, caching derivable results in the provided per-item cache. + /// If value is not available, returns empty string. + /// + internal static string GetMetadataValueEscaped( + string currentDirectory, + string evaluatedIncludeBeforeWildcardExpansionEscaped, + string evaluatedIncludeEscaped, + string definingProjectEscaped, + ItemSpecModifierKind modifierKind, + ref ItemSpecModifiers.Cache cache) + => modifierKind is ItemSpecModifierKind.RecursiveDir + ? GetRecursiveDirValue(evaluatedIncludeBeforeWildcardExpansionEscaped, evaluatedIncludeEscaped) + : ItemSpecModifiers.GetItemSpecModifier(evaluatedIncludeEscaped, modifierKind, currentDirectory, definingProjectEscaped, ref cache); + /// /// Extract the value for "RecursiveDir", if any, from the Include. /// If there is none, returns an empty string. diff --git a/src/Build/Definition/ProjectItem.cs b/src/Build/Definition/ProjectItem.cs index 19c954401c2..2e07889568b 100644 --- a/src/Build/Definition/ProjectItem.cs +++ b/src/Build/Definition/ProjectItem.cs @@ -100,9 +100,9 @@ public class ProjectItem : IItem, IProjectMetadataParent, IItem private PropertyDictionary _directMetadata; /// - /// Cached value of the fullpath metadata. All other metadata are computed on demand. + /// Cached values of derivable item-spec modifiers. All time-based metadata are computed on demand. /// - private string _fullPath; + private ItemSpecModifiers.Cache _cachedModifiers; /// /// External projects support @@ -299,12 +299,7 @@ public ICollection Metadata /// Includes any metadata inherited from item definitions. /// Includes both custom and built-in metadata. /// - public int MetadataCount - { - [DebuggerStepThrough] - get - { return Metadata.Count + ItemSpecModifiers.All.Length; } - } + public int MetadataCount => Metadata.Count + ItemSpecModifiers.All.Length; /// /// Implementation of IKeyed exposing the item type, so items @@ -700,7 +695,7 @@ public void Rename(string name) return; } - _fullPath = null; // Clear cached value + _cachedModifiers.Clear(); // Clear cached values if (_xml.Count == 0 /* no metadata */ && _project.IsSuitableExistingItemXml(_xml, name, null /* no metadata */) && !FileMatcher.HasWildcardsSemicolonItemOrPropertyReferences(name)) { @@ -854,16 +849,9 @@ internal void SplitOwnItemElement() /// the specified name, if any. /// private string GetBuiltInMetadataEscaped(string name) - { - string value = null; - - if (ItemSpecModifiers.IsItemSpecModifier(name)) - { - value = BuiltInMetadata.GetMetadataValueEscaped(_project.DirectoryPath, _evaluatedIncludeBeforeWildcardExpansionEscaped, _evaluatedIncludeEscaped, this.Xml.ContainingProject.FullPath, name, ref _fullPath); - } - - return value; - } + => ItemSpecModifiers.TryGetModifierKind(name, out ItemSpecModifierKind modifierKind) + ? BuiltInMetadata.GetMetadataValueEscaped(_project.DirectoryPath, _evaluatedIncludeBeforeWildcardExpansionEscaped, _evaluatedIncludeEscaped, Xml.ContainingProject.FullPath, modifierKind, ref _cachedModifiers) + : null; /// /// Retrieves the named metadata from the item definition, if any. diff --git a/src/Build/Evaluation/Expander.cs b/src/Build/Evaluation/Expander.cs index f9fc77ec5a6..64a1ce461cf 100644 --- a/src/Build/Evaluation/Expander.cs +++ b/src/Build/Evaluation/Expander.cs @@ -2560,7 +2560,7 @@ internal static void ItemSpecModifierFunction(IElementLocation elementLocation, string directoryToUse = item.Value.ProjectDirectory ?? FileUtilities.CurrentThreadWorkingDirectory ?? Directory.GetCurrentDirectory(); string definingProjectEscaped = item.Value.GetMetadataValueEscaped(ItemSpecModifiers.DefiningProjectFullPath); - result = ItemSpecModifiers.GetItemSpecModifier(directoryToUse, item.Key, definingProjectEscaped, functionName); + result = ItemSpecModifiers.GetItemSpecModifier(item.Key, functionName, directoryToUse, definingProjectEscaped); } // InvalidOperationException is how GetItemSpecModifier communicates invalid conditions upwards, so // we do not want to rethrow in that case. @@ -3328,7 +3328,7 @@ private static string GetMetadataValueFromMatch( string directoryToUse = sourceOfMetadata.ProjectDirectory ?? FileUtilities.CurrentThreadWorkingDirectory ?? Directory.GetCurrentDirectory(); string definingProjectEscaped = sourceOfMetadata.GetMetadataValueEscaped(ItemSpecModifiers.DefiningProjectFullPath); - value = ItemSpecModifiers.GetItemSpecModifier(directoryToUse, itemSpec, definingProjectEscaped, match.Name); + value = ItemSpecModifiers.GetItemSpecModifier(itemSpec, match.Name, directoryToUse, definingProjectEscaped); } else { diff --git a/src/Build/Instance/ProjectItemInstance.cs b/src/Build/Instance/ProjectItemInstance.cs index b8a70ceac25..0550a44977a 100644 --- a/src/Build/Instance/ProjectItemInstance.cs +++ b/src/Build/Instance/ProjectItemInstance.cs @@ -817,9 +817,9 @@ internal sealed class TaskItem : private IReadOnlyDictionary _directMetadata; /// - /// Cached value of the fullpath metadata. All other metadata are computed on demand. + /// Cached values of derivable item-spec modifiers. All time-based metadata are computed on demand. /// - private string _fullPath; + private ItemSpecModifiers.Cache _cachedModifiers; /// /// All the item definitions that apply to this item, in order of @@ -893,7 +893,7 @@ private TaskItem(TaskItem source, bool addOriginalItemSpec) _includeEscaped = source._includeEscaped; _includeBeforeWildcardExpansionEscaped = source._includeBeforeWildcardExpansionEscaped; source.CopyMetadataTo(this, addOriginalItemSpec); - _fullPath = source._fullPath; + _cachedModifiers = source._cachedModifiers; _definingFileEscaped = source._definingFileEscaped; } @@ -936,7 +936,7 @@ public string ItemSpec ErrorUtilities.VerifyThrowArgumentNull(value, "ItemSpec"); _includeEscaped = value; - _fullPath = null; // Clear cached value + _cachedModifiers.Clear(); // Clear cached values } } @@ -981,7 +981,10 @@ public ICollection MetadataNames names.Add(metadatum.Key); } - names.AddRange(ItemSpecModifiers.All); + foreach (string name in ItemSpecModifiers.All) + { + names.Add(name); + } return names; } @@ -1054,7 +1057,7 @@ internal string IncludeEscaped ErrorUtilities.VerifyThrowArgumentLength(value, "IncludeEscaped"); _includeEscaped = value; - _fullPath = null; // Clear cached value + _cachedModifiers.Clear(); // Clear cached values } } @@ -2081,16 +2084,9 @@ internal TaskItem DeepClone(bool isImmutable) /// If value is not available, returns empty string. /// private string GetBuiltInMetadataEscaped(string name) - { - string value = String.Empty; - - if (ItemSpecModifiers.IsItemSpecModifier(name)) - { - value = BuiltInMetadata.GetMetadataValueEscaped(_projectDirectory, _includeBeforeWildcardExpansionEscaped, _includeEscaped, _definingFileEscaped, name, ref _fullPath); - } - - return value; - } + => ItemSpecModifiers.TryGetModifierKind(name, out ItemSpecModifierKind modifierKind) + ? BuiltInMetadata.GetMetadataValueEscaped(_projectDirectory, _includeBeforeWildcardExpansionEscaped, _includeEscaped, _definingFileEscaped, modifierKind, ref _cachedModifiers) + : string.Empty; /// /// Retrieves the named metadata from the item definition, if any. diff --git a/src/Build/Instance/ProjectMetadataInstance.cs b/src/Build/Instance/ProjectMetadataInstance.cs index 548f35ff5fd..a37a6f5e3c5 100644 --- a/src/Build/Instance/ProjectMetadataInstance.cs +++ b/src/Build/Instance/ProjectMetadataInstance.cs @@ -241,12 +241,10 @@ internal static void VerifyThrowReservedName(string name) // PERF: This sequence of checks is faster than a full HashSet lookup since finding a match is an error case. // Otherwise, many keys would still match to a bucket and begin a string comparison. VerifyThrowReservedNameAllowItemSpecModifiers(name); - foreach (string itemSpecModifier in ItemSpecModifiers.All) + + if (ItemSpecModifiers.IsItemSpecModifier(name)) { - if (itemSpecModifier.Length == name.Length && itemSpecModifier[0] == char.ToUpperInvariant(name[0])) - { - ErrorUtilities.VerifyThrowArgument(!MSBuildNameIgnoreCaseComparer.Default.Equals(itemSpecModifier, name), "OM_ReservedName", name); - } + ErrorUtilities.ThrowArgument("OM_ReservedName", name); } } diff --git a/src/Framework.UnitTests/FileUtilities_Tests.cs b/src/Framework.UnitTests/FileUtilities_Tests.cs index 6e8965ade92..a69c632765b 100644 --- a/src/Framework.UnitTests/FileUtilities_Tests.cs +++ b/src/Framework.UnitTests/FileUtilities_Tests.cs @@ -32,56 +32,56 @@ public void GetItemSpecModifier() private static void TestGetItemSpecModifier(string currentDirectory) { - string cache = null; - string modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, "foo", String.Empty, ItemSpecModifiers.RecursiveDir, ref cache); + ItemSpecModifiers.Cache cache = default; + string modifier = ItemSpecModifiers.GetItemSpecModifier("foo", ItemSpecModifierKind.RecursiveDir, currentDirectory, String.Empty, ref cache); Assert.Equal(String.Empty, modifier); - cache = null; - modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, "foo", String.Empty, ItemSpecModifiers.ModifiedTime, ref cache); + cache.Clear(); + modifier = ItemSpecModifiers.GetItemSpecModifier("foo", ItemSpecModifierKind.ModifiedTime, currentDirectory, String.Empty, ref cache); Assert.Equal(String.Empty, modifier); - cache = null; - modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, @"foo\goo", String.Empty, ItemSpecModifiers.RelativeDir, ref cache); + cache.Clear(); + modifier = ItemSpecModifiers.GetItemSpecModifier(@"foo\goo", ItemSpecModifierKind.RelativeDir, currentDirectory, String.Empty, ref cache); Assert.Equal(@"foo" + Path.DirectorySeparatorChar, modifier); // confirm we get the same thing back the second time - modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, @"foo\goo", String.Empty, ItemSpecModifiers.RelativeDir, ref cache); + modifier = ItemSpecModifiers.GetItemSpecModifier(@"foo\goo", ItemSpecModifierKind.RelativeDir, currentDirectory, String.Empty, ref cache); Assert.Equal(@"foo" + Path.DirectorySeparatorChar, modifier); - cache = null; + cache.Clear(); string itemSpec = NativeMethodsShared.IsWindows ? @"c:\foo.txt" : "/foo.txt"; string itemSpecDir = NativeMethodsShared.IsWindows ? @"c:\" : "/"; - modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, String.Empty, ItemSpecModifiers.FullPath, ref cache); + modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.FullPath, currentDirectory, String.Empty, ref cache); Assert.Equal(itemSpec, modifier); - Assert.Equal(itemSpec, cache); + Assert.Equal(itemSpec, cache.FullPath); - modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, String.Empty, ItemSpecModifiers.RootDir, ref cache); + modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.RootDir, currentDirectory, String.Empty, ref cache); Assert.Equal(itemSpecDir, modifier); - modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, String.Empty, ItemSpecModifiers.Filename, ref cache); + modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.Filename, currentDirectory, String.Empty, ref cache); Assert.Equal(@"foo", modifier); - modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, String.Empty, ItemSpecModifiers.Extension, ref cache); + modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.Extension, currentDirectory, String.Empty, ref cache); Assert.Equal(@".txt", modifier); - modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, String.Empty, ItemSpecModifiers.Directory, ref cache); + modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.Directory, currentDirectory, String.Empty, ref cache); Assert.Equal(String.Empty, modifier); - modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, String.Empty, ItemSpecModifiers.Identity, ref cache); + modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.Identity, currentDirectory, String.Empty, ref cache); Assert.Equal(itemSpec, modifier); string projectPath = NativeMethodsShared.IsWindows ? @"c:\abc\goo.proj" : @"/abc/goo.proj"; string projectPathDir = NativeMethodsShared.IsWindows ? @"c:\abc\" : @"/abc/"; - modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, projectPath, ItemSpecModifiers.DefiningProjectDirectory, ref cache); + modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.DefiningProjectDirectory, currentDirectory, projectPath, ref cache); Assert.Equal(projectPathDir, modifier); - modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, projectPath, ItemSpecModifiers.DefiningProjectExtension, ref cache); + modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.DefiningProjectExtension, currentDirectory, projectPath, ref cache); Assert.Equal(@".proj", modifier); - modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, projectPath, ItemSpecModifiers.DefiningProjectFullPath, ref cache); + modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.DefiningProjectFullPath, currentDirectory, projectPath, ref cache); Assert.Equal(projectPath, modifier); - modifier = ItemSpecModifiers.GetItemSpecModifier(currentDirectory, itemSpec, projectPath, ItemSpecModifiers.DefiningProjectName, ref cache); + modifier = ItemSpecModifiers.GetItemSpecModifier(itemSpec, ItemSpecModifierKind.DefiningProjectName, currentDirectory, projectPath, ref cache); Assert.Equal(@"goo", modifier); } @@ -175,8 +175,7 @@ private static void TestGetItemSpecModifierOnBadPath(string currentDirectory) { try { - string cache = null; - ItemSpecModifiers.GetItemSpecModifier(currentDirectory, @"http://www.microsoft.com", String.Empty, ItemSpecModifiers.RootDir, ref cache); + ItemSpecModifiers.GetItemSpecModifier(@"http://www.microsoft.com", ItemSpecModifiers.RootDir, currentDirectory, String.Empty); } catch (Exception e) { @@ -440,9 +439,9 @@ public void GetItemSpecModifierRootDirThatFitsIntoMaxPath() { string currentDirectory = @"c:\aardvark\aardvark\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890"; string fullPath = @"c:\aardvark\aardvark\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\1234567890\a.cs"; - string cache = fullPath; + ItemSpecModifiers.Cache cache = new() { FullPath = fullPath }; - Assert.Equal(@"c:\", ItemSpecModifiers.GetItemSpecModifier(currentDirectory, fullPath, String.Empty, ItemSpecModifiers.RootDir, ref cache)); + Assert.Equal(@"c:\", ItemSpecModifiers.GetItemSpecModifier(fullPath, ItemSpecModifierKind.RootDir, currentDirectory, String.Empty, ref cache)); } [Fact] diff --git a/src/Framework/ItemSpecModifierKind.cs b/src/Framework/ItemSpecModifierKind.cs new file mode 100644 index 00000000000..cf0e7fffe92 --- /dev/null +++ b/src/Framework/ItemSpecModifierKind.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Build.Framework; + +internal enum ItemSpecModifierKind +{ + FullPath, + RootDir, + Filename, + Extension, + RelativeDir, + Directory, + RecursiveDir, + Identity, + ModifiedTime, + CreatedTime, + AccessedTime, + DefiningProjectFullPath, + DefiningProjectDirectory, + DefiningProjectName, + DefiningProjectExtension +} diff --git a/src/Framework/ItemSpecModifiers.cs b/src/Framework/ItemSpecModifiers.cs index 7e87652adb7..aded6fb420a 100644 --- a/src/Framework/ItemSpecModifiers.cs +++ b/src/Framework/ItemSpecModifiers.cs @@ -2,40 +2,39 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Frozen; +using System.Collections.Concurrent; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Runtime.CompilerServices; using Microsoft.Build.Shared; -using Microsoft.Build.Shared.FileSystem; - -#nullable disable namespace Microsoft.Build.Framework; /// -/// Encapsulates the definitions of the item-spec modifiers a.k.a. reserved item metadata. +/// Encapsulates the definitions of the item-spec modifiers a.k.a. reserved item metadata. /// internal static class ItemSpecModifiers { - internal const string FullPath = "FullPath"; - internal const string RootDir = "RootDir"; - internal const string Filename = "Filename"; - internal const string Extension = "Extension"; - internal const string RelativeDir = "RelativeDir"; - internal const string Directory = "Directory"; - internal const string RecursiveDir = "RecursiveDir"; - internal const string Identity = "Identity"; - internal const string ModifiedTime = "ModifiedTime"; - internal const string CreatedTime = "CreatedTime"; - internal const string AccessedTime = "AccessedTime"; - internal const string DefiningProjectFullPath = "DefiningProjectFullPath"; - internal const string DefiningProjectDirectory = "DefiningProjectDirectory"; - internal const string DefiningProjectName = "DefiningProjectName"; - internal const string DefiningProjectExtension = "DefiningProjectExtension"; + public const string FullPath = "FullPath"; + public const string RootDir = "RootDir"; + public const string Filename = "Filename"; + public const string Extension = "Extension"; + public const string RelativeDir = "RelativeDir"; + public const string Directory = "Directory"; + public const string RecursiveDir = "RecursiveDir"; + public const string Identity = "Identity"; + public const string ModifiedTime = "ModifiedTime"; + public const string CreatedTime = "CreatedTime"; + public const string AccessedTime = "AccessedTime"; + public const string DefiningProjectFullPath = "DefiningProjectFullPath"; + public const string DefiningProjectDirectory = "DefiningProjectDirectory"; + public const string DefiningProjectName = "DefiningProjectName"; + public const string DefiningProjectExtension = "DefiningProjectExtension"; // These are all the well-known attributes. - internal static readonly string[] All = - { + public static readonly ImmutableArray All = + [ FullPath, RootDir, Filename, @@ -51,76 +50,327 @@ internal static class ItemSpecModifiers DefiningProjectDirectory, DefiningProjectName, DefiningProjectExtension - }; + ]; - private static readonly FrozenSet s_tableOfItemSpecModifiers = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, All); - private static readonly FrozenSet s_tableOfDefiningProjectModifiers = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, - [ - DefiningProjectFullPath, - DefiningProjectDirectory, - DefiningProjectName, - DefiningProjectExtension, - ]); + /// + /// + /// Caches derivable item-spec modifier results for a single item spec. + /// Stored on item instances (e.g., TaskItem, ProjectItemInstance.TaskItem) + /// alongside the item spec, replacing the former string _fullPath field. + /// + /// + /// Time-based modifiers (ModifiedTime, CreatedTime, AccessedTime) and RecursiveDir + /// are intentionally excluded — time-based modifiers hit the file system and should + /// not be cached, and RecursiveDir requires wildcard context that only the caller has. + /// + /// + /// DefiningProject* modifiers are cached separately in a static shared cache + /// () keyed by the defining project path, + /// since many items share the same defining project. + /// + /// + internal struct Cache + { + public string? FullPath; + public string? RootDir; + public string? Filename; + public string? Extension; + public string? RelativeDir; + public string? Directory; + + /// + /// Clears all cached values. Called when the item spec changes. + /// + public void Clear() + => this = default; + } /// - /// Indicates if the given name is reserved for an item-spec modifier. + /// Cached results for all four DefiningProject* modifiers, computed from a single + /// defining project path. Instances are shared across all items that originate from + /// the same project file. /// - internal static bool IsItemSpecModifier(string name) + private sealed class DefiningProjectModifierCache { - if (name == null) + public readonly string FullPath; + public readonly string Directory; + public readonly string Name; + public readonly string Extension; + + public DefiningProjectModifierCache(string? currentDirectory, string definingProjectEscaped) { - return false; + FullPath = ComputeFullPath(currentDirectory, definingProjectEscaped); + string rootDir = ComputeRootDir(FullPath); + string directory = ComputeDirectory(FullPath); + Directory = Path.Combine(rootDir, directory); + Name = ComputeFilename(definingProjectEscaped); + Extension = ComputeExtension(definingProjectEscaped); } - - // Could still be a case-insensitive match. - bool result = s_tableOfItemSpecModifiers.Contains(name); - - return result; } /// - /// Indicates if the given name is reserved for one of the specific subset of itemspec - /// modifiers to do with the defining project of the item. + /// Static cache of DefiningProject* results keyed by the escaped defining project path. + /// In a typical build there are only a handful of distinct defining projects (tens, not thousands), + /// so this dictionary stays very small. The cache lives for the lifetime of the process. /// - internal static bool IsDefiningProjectModifier(string name) => s_tableOfDefiningProjectModifiers.Contains(name); + private static readonly ConcurrentDictionary s_definingProjectCache = + new(StringComparer.OrdinalIgnoreCase); /// - /// Indicates if the given name is reserved for a derivable item-spec modifier. - /// Derivable means it can be computed given a file name. + /// Clears the static DefiningProject* modifier cache. Call at the end of a build + /// (e.g., from BuildManager.EndBuild) to prevent stale entries from accumulating + /// in long-lived processes such as Visual Studio. /// - /// Name to check. - /// true, if name of a derivable modifier - internal static bool IsDerivableItemSpecModifier(string name) - { - bool isItemSpecModifier = IsItemSpecModifier(name); + public static void ClearDefiningProjectCache() + => s_definingProjectCache.Clear(); - if (isItemSpecModifier) + /// + /// Resolves a modifier name to its using a length+char switch + /// instead of a dictionary lookup. Every length bucket is unique or disambiguated by at + /// most two character comparisons, so misses are rejected in O(1) with no hashing. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetModifierKind(string name, out ItemSpecModifierKind kind) + { + switch (name.Length) { - if (name.Length == 12) - { - if (name[0] == 'R' || name[0] == 'r') + case 7: + // RootDir + if (string.Equals(name, RootDir, StringComparison.OrdinalIgnoreCase)) { - // The only 12 letter ItemSpecModifier that starts with 'R' is 'RecursiveDir' - return false; + kind = ItemSpecModifierKind.RootDir; + return true; } - } + + break; + + case 8: + // FullPath, Filename, Identity + switch (name[0]) + { + case 'F' or 'f': + switch (name[1]) + { + case 'U' or 'u': + if (string.Equals(name, FullPath, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.FullPath; + return true; + } + + break; + + case 'I' or 'i': + if (string.Equals(name, Filename, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.Filename; + return true; + } + + break; + } + + break; + + case 'I' or 'i': + if (string.Equals(name, Identity, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.Identity; + return true; + } + + break; + } + + break; + + case 9: + // Extension, Directory + switch (name[0]) + { + case 'E' or 'e': + if (string.Equals(name, Extension, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.Extension; + return true; + } + + break; + + case 'D' or 'd': + if (string.Equals(name, Directory, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.Directory; + return true; + } + + break; + } + + break; + + case 11: + // RelativeDir, CreatedTime + switch (name[0]) + { + case 'R' or 'r': + if (string.Equals(name, RelativeDir, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.RelativeDir; + return true; + } + + break; + + case 'C' or 'c': + if (string.Equals(name, CreatedTime, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.CreatedTime; + return true; + } + + break; + } + + break; + + case 12: + // RecursiveDir, ModifiedTime, AccessedTime + switch (name[0]) + { + case 'R' or 'r': + if (string.Equals(name, RecursiveDir, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.RecursiveDir; + return true; + } + + break; + + case 'M' or 'm': + if (string.Equals(name, ModifiedTime, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.ModifiedTime; + return true; + } + + break; + + case 'A' or 'a': + if (string.Equals(name, AccessedTime, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.AccessedTime; + return true; + } + + break; + } + + break; + + case 19: + // DefiningProjectName + if (string.Equals(name, DefiningProjectName, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.DefiningProjectName; + return true; + } + + break; + + case 23: + // DefiningProjectFullPath + if (string.Equals(name, DefiningProjectFullPath, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.DefiningProjectFullPath; + return true; + } + + break; + + case 24: + // DefiningProjectDirectory, DefiningProjectExtension + switch (name[15]) + { + case 'D' or 'd': + if (string.Equals(name, DefiningProjectDirectory, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.DefiningProjectDirectory; + return true; + } + + break; + + case 'E' or 'e': + if (string.Equals(name, DefiningProjectExtension, StringComparison.OrdinalIgnoreCase)) + { + kind = ItemSpecModifierKind.DefiningProjectExtension; + return true; + } + + break; + } + + break; } - return isItemSpecModifier; + kind = default; + return false; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetDerivableModifierKind(string name, out ItemSpecModifierKind result) + { + if (TryGetModifierKind(name, out ItemSpecModifierKind kind) && + kind is not ItemSpecModifierKind.RecursiveDir) + { + result = kind; + return true; + } + + result = default; + return false; + } + + /// + /// Indicates if the given name is reserved for an item-spec modifier. + /// + public static bool IsItemSpecModifier([NotNullWhen(true)] string? name) + => name is not null + && TryGetModifierKind(name, out _); + + /// + /// Indicates if the given name is reserved for a derivable item-spec modifier. + /// Derivable means it can be computed given a file name. + /// + /// Name to check. + /// true, if name of a derivable modifier + public static bool IsDerivableItemSpecModifier([NotNullWhen(true)] string? name) + => name is not null + && TryGetDerivableModifierKind(name, out _); + /// - /// Performs path manipulations on the given item-spec as directed. - /// Does not cache the result. + /// Performs path manipulations on the given item-spec as directed. + /// Does not cache the result. /// - internal static string GetItemSpecModifier(string currentDirectory, string itemSpec, string definingProjectEscaped, string modifier) + /// The item-spec to modify. + /// The modifier to apply to the item-spec. + /// The root directory for relative item-specs. + /// The path to the project that defined this item (may be null). + public static string GetItemSpecModifier(string itemSpec, string modifier, string? currentDirectory, string? definingProjectEscaped) { - string dummy = null; - return GetItemSpecModifier(currentDirectory, itemSpec, definingProjectEscaped, modifier, ref dummy); + if (!TryGetModifierKind(modifier, out ItemSpecModifierKind kind)) + { + throw new InternalErrorException($"\"{modifier}\" is not a valid item-spec modifier."); + } + + Cache cache = default; + return GetItemSpecModifier(itemSpec, kind, currentDirectory, definingProjectEscaped, ref cache); } /// - /// Performs path manipulations on the given item-spec as directed. + /// Performs path manipulations on the given item-spec as directed, caching + /// derivable results in for subsequent calls on the same item spec. /// /// Supported modifiers: /// %(FullPath) = full path of item @@ -138,251 +388,238 @@ internal static string GetItemSpecModifier(string currentDirectory, string itemS /// NOTES: /// 1) This method always returns an empty string for the %(RecursiveDir) modifier because it does not have enough /// information to compute it -- only the BuildItem class can compute this modifier. - /// 2) All but the file time modifiers could be cached, but it's not worth the space. Only full path is cached, as the others are just string manipulations. + /// 2) Time-based modifiers are not cached — they hit the file system and may change between calls. + /// 3) DefiningProject* modifiers operate on , not . + /// Their results are cached in a static shared cache keyed by the defining project path, since many + /// items share the same defining project and the set of distinct projects is small (typically tens). /// /// - /// Methods of the Path class "normalize" slashes and periods. For example: - /// 1) successive slashes are combined into 1 slash - /// 2) trailing periods are discarded - /// 3) forward slashes are changed to back-slashes - /// - /// As a result, we cannot rely on any file-spec that has passed through a Path method to remain the same. We will - /// therefore not bother preserving slashes and periods when file-specs are transformed. - /// /// Never returns null. /// - /// The root directory for relative item-specs. When called on the Engine thread, this is the project directory. When called as part of building a task, it is null, indicating that the current directory should be used. /// The item-spec to modify. - /// The path to the project that defined this item (may be null). /// The modifier to apply to the item-spec. - /// Full path if any was previously computed, to cache. + /// The root directory for relative item-specs. + /// The path to the project that defined this item (may be null). + /// Per-item cache of derivable modifier values. /// The modified item-spec (can be empty string, but will never be null). /// Thrown when the item-spec is not a path. - [SuppressMessage("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity", Justification = "Pre-existing")] - internal static string GetItemSpecModifier(string currentDirectory, string itemSpec, string definingProjectEscaped, string modifier, ref string fullPath) + public static string GetItemSpecModifier( + string itemSpec, + ItemSpecModifierKind modifier, + string? currentDirectory, + string? definingProjectEscaped, + ref Cache cache) { FrameworkErrorUtilities.VerifyThrow(itemSpec != null, "Need item-spec to modify."); - FrameworkErrorUtilities.VerifyThrow(modifier != null, "Need modifier to apply to item-spec."); - - string modifiedItemSpec = null; try { - if (string.Equals(modifier, FullPath, StringComparison.OrdinalIgnoreCase)) + switch (modifier) { - if (fullPath != null) - { - return fullPath; - } + case ItemSpecModifierKind.FullPath: + return cache.FullPath ??= ComputeFullPath(currentDirectory, itemSpec); - if (currentDirectory == null) - { - currentDirectory = FileUtilities.CurrentThreadWorkingDirectory ?? string.Empty; - } + case ItemSpecModifierKind.RootDir: + return cache.RootDir ??= ComputeRootDir(cache.FullPath ??= ComputeFullPath(currentDirectory, itemSpec)); - modifiedItemSpec = FileUtilities.GetFullPath(itemSpec, currentDirectory); - fullPath = modifiedItemSpec; + case ItemSpecModifierKind.Filename: + return cache.Filename ??= ComputeFilename(itemSpec); - ThrowForUrl(modifiedItemSpec, itemSpec, currentDirectory); - } - else if (string.Equals(modifier, RootDir, StringComparison.OrdinalIgnoreCase)) - { - GetItemSpecModifier(currentDirectory, itemSpec, definingProjectEscaped, FullPath, ref fullPath); + case ItemSpecModifierKind.Extension: + return cache.Extension ??= ComputeExtension(itemSpec); - modifiedItemSpec = Path.GetPathRoot(fullPath); + case ItemSpecModifierKind.RelativeDir: + return cache.RelativeDir ??= ComputeRelativeDir(itemSpec); - if (!FileUtilities.EndsWithSlash(modifiedItemSpec)) - { - FrameworkErrorUtilities.VerifyThrow( - FileUtilitiesRegex.StartsWithUncPattern(modifiedItemSpec), - "Only UNC shares should be missing trailing slashes."); + case ItemSpecModifierKind.Directory: + return cache.Directory ??= ComputeDirectory(cache.FullPath ??= ComputeFullPath(currentDirectory, itemSpec)); - // restore/append trailing slash if Path.GetPathRoot() has either removed it, or failed to add it - // (this happens with UNC shares) - modifiedItemSpec += Path.DirectorySeparatorChar; - } - } - else if (string.Equals(modifier, Filename, StringComparison.OrdinalIgnoreCase)) - { - // if the item-spec is a root directory, it can have no filename - if (IsRootDirectory(itemSpec)) - { - // NOTE: this is to prevent Path.GetFileNameWithoutExtension() from treating server and share elements - // in a UNC file-spec as filenames e.g. \\server, \\server\share - modifiedItemSpec = string.Empty; - } - else - { - // Fix path to avoid problem with Path.GetFileNameWithoutExtension when backslashes in itemSpec on Unix - modifiedItemSpec = Path.GetFileNameWithoutExtension(FileUtilities.FixFilePath(itemSpec)); - } - } - else if (string.Equals(modifier, Extension, StringComparison.OrdinalIgnoreCase)) - { - // if the item-spec is a root directory, it can have no extension - if (IsRootDirectory(itemSpec)) - { - // NOTE: this is to prevent Path.GetExtension() from treating server and share elements in a UNC - // file-spec as filenames e.g. \\server.ext, \\server\share.ext - modifiedItemSpec = string.Empty; - } - else - { - modifiedItemSpec = Path.GetExtension(itemSpec); - } - } - else if (string.Equals(modifier, RelativeDir, StringComparison.OrdinalIgnoreCase)) - { - modifiedItemSpec = FileUtilities.GetDirectory(itemSpec); - } - else if (string.Equals(modifier, Directory, StringComparison.OrdinalIgnoreCase)) - { - GetItemSpecModifier(currentDirectory, itemSpec, definingProjectEscaped, FullPath, ref fullPath); + case ItemSpecModifierKind.RecursiveDir: + return string.Empty; - modifiedItemSpec = FileUtilities.GetDirectory(fullPath); + case ItemSpecModifierKind.Identity: + return itemSpec; - if (NativeMethods.IsWindows) - { - int length = -1; - if (FileUtilitiesRegex.StartsWithDrivePattern(modifiedItemSpec)) - { - length = 2; - } - else - { - length = FileUtilitiesRegex.StartsWithUncPatternMatchLength(modifiedItemSpec); - } - - if (length != -1) - { - FrameworkErrorUtilities.VerifyThrow((modifiedItemSpec.Length > length) && FileUtilities.IsSlash(modifiedItemSpec[length]), - "Root directory must have a trailing slash."); - - modifiedItemSpec = modifiedItemSpec.Substring(length + 1); - } - } - else - { - FrameworkErrorUtilities.VerifyThrow(!string.IsNullOrEmpty(modifiedItemSpec) && FileUtilities.IsSlash(modifiedItemSpec[0]), - "Expected a full non-windows path rooted at '/'."); + // Time-based modifiers are NOT cached - they hit the file system. + case ItemSpecModifierKind.ModifiedTime: + return ComputeModifiedTime(itemSpec); - // A full unix path is always rooted at - // `/`, and a root-relative path is the - // rest of the string. - modifiedItemSpec = modifiedItemSpec.Substring(1); - } + case ItemSpecModifierKind.CreatedTime: + return ComputeCreatedTime(itemSpec); + + case ItemSpecModifierKind.AccessedTime: + return ComputeAccessedTime(itemSpec); + + default: + break; } - else if (string.Equals(modifier, RecursiveDir, StringComparison.OrdinalIgnoreCase)) + + // DefiningProject* modifiers — these operate on definingProjectEscaped, NOT itemSpec. + // Results are cached in a static shared dictionary keyed by the defining project path. + if (string.IsNullOrEmpty(definingProjectEscaped)) { - // only the BuildItem class can compute this modifier -- so leave empty - modifiedItemSpec = String.Empty; + return string.Empty; } - else if (string.Equals(modifier, Identity, StringComparison.OrdinalIgnoreCase)) + + FrameworkErrorUtilities.VerifyThrow(definingProjectEscaped != null, "How could definingProjectEscaped by null?"); + + // Fast path: check if we already have cached results for this defining project. + // This avoids any closure allocation on the hot path. The miss path only runs once per distinct defining project. + if (!s_definingProjectCache.TryGetValue(definingProjectEscaped, out DefiningProjectModifierCache? definingProjectModifiers)) { - modifiedItemSpec = itemSpec; + string? dir = currentDirectory; + definingProjectModifiers = s_definingProjectCache.GetOrAdd( + definingProjectEscaped, + key => new DefiningProjectModifierCache(dir, key)); } - else if (string.Equals(modifier, ModifiedTime, StringComparison.OrdinalIgnoreCase)) + + switch (modifier) { - // About to go out to the filesystem. This means data is leaving the engine, so need - // to unescape first. - string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec); + case ItemSpecModifierKind.DefiningProjectFullPath: + return definingProjectModifiers.FullPath; - FileInfo info = FileUtilities.GetFileInfoNoThrow(unescapedItemSpec); + case ItemSpecModifierKind.DefiningProjectDirectory: + return definingProjectModifiers.Directory; - if (info != null) - { - modifiedItemSpec = info.LastWriteTime.ToString(FileUtilities.FileTimeFormat, null); - } - else - { - // File does not exist, or path is a directory - modifiedItemSpec = String.Empty; - } - } - else if (string.Equals(modifier, CreatedTime, StringComparison.OrdinalIgnoreCase)) - { - // About to go out to the filesystem. This means data is leaving the engine, so need - // to unescape first. - string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec); + case ItemSpecModifierKind.DefiningProjectName: + return definingProjectModifiers.Name; - if (FileSystems.Default.FileExists(unescapedItemSpec)) - { - modifiedItemSpec = File.GetCreationTime(unescapedItemSpec).ToString(FileUtilities.FileTimeFormat, null); - } - else - { - // File does not exist, or path is a directory - modifiedItemSpec = String.Empty; - } + case ItemSpecModifierKind.DefiningProjectExtension: + return definingProjectModifiers.Extension; } - else if (string.Equals(modifier, AccessedTime, StringComparison.OrdinalIgnoreCase)) - { - // About to go out to the filesystem. This means data is leaving the engine, so need - // to unescape first. - string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec); + } + catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) + { + throw new InvalidOperationException(SR.FormatInvalidFilespecForTransform(modifier, itemSpec, e.Message)); + } - if (FileSystems.Default.FileExists(unescapedItemSpec)) - { - modifiedItemSpec = File.GetLastAccessTime(unescapedItemSpec).ToString(FileUtilities.FileTimeFormat, null); - } - else - { - // File does not exist, or path is a directory - modifiedItemSpec = String.Empty; - } - } - else if (IsDefiningProjectModifier(modifier)) - { - if (String.IsNullOrEmpty(definingProjectEscaped)) - { - // We have nothing to work with, but that's sometimes OK -- so just return String.Empty - modifiedItemSpec = String.Empty; - } - else - { - if (string.Equals(modifier, DefiningProjectDirectory, StringComparison.OrdinalIgnoreCase)) - { - // ItemSpecModifiers.Directory does not contain the root directory - modifiedItemSpec = Path.Combine( - GetItemSpecModifier(currentDirectory, definingProjectEscaped, null, RootDir), - GetItemSpecModifier(currentDirectory, definingProjectEscaped, null, Directory)); - } - else - { - string additionalModifier = null; - - if (string.Equals(modifier, DefiningProjectFullPath, StringComparison.OrdinalIgnoreCase)) - { - additionalModifier = FullPath; - } - else if (string.Equals(modifier, DefiningProjectName, StringComparison.OrdinalIgnoreCase)) - { - additionalModifier = Filename; - } - else if (string.Equals(modifier, DefiningProjectExtension, StringComparison.OrdinalIgnoreCase)) - { - additionalModifier = Extension; - } - else - { - throw new InternalErrorException($"\"{modifier}\" is not a valid item-spec modifier."); - } + throw new InternalErrorException($"\"{modifier}\" is not a valid item-spec modifier."); + } - modifiedItemSpec = GetItemSpecModifier(currentDirectory, definingProjectEscaped, null, additionalModifier); - } - } + private static string ComputeFullPath(string? currentDirectory, string itemSpec) + { + currentDirectory ??= FileUtilities.CurrentThreadWorkingDirectory ?? string.Empty; + + string result = FileUtilities.GetFullPath(itemSpec, currentDirectory); + + ThrowForUrl(result, itemSpec, currentDirectory); + + return result; + } + + private static string ComputeRootDir(string fullPath) + { + string? root = Path.GetPathRoot(fullPath)!; + + if (!FileUtilities.EndsWithSlash(root)) + { + FrameworkErrorUtilities.VerifyThrow( + FileUtilitiesRegex.StartsWithUncPattern(root), + "Only UNC shares should be missing trailing slashes."); + + // restore/append trailing slash if Path.GetPathRoot() has either removed it, or failed to add it + // (this happens with UNC shares) + root += Path.DirectorySeparatorChar; + } + + return root; + } + + private static string ComputeFilename(string itemSpec) + { + // if the item-spec is a root directory, it can have no filename + if (IsRootDirectory(itemSpec)) + { + // NOTE: this is to prevent Path.GetFileNameWithoutExtension() from treating server and share elements + // in a UNC file-spec as filenames e.g. \\server, \\server\share + return string.Empty; + } + else + { + // Fix path to avoid problem with Path.GetFileNameWithoutExtension when backslashes in itemSpec on Unix + return Path.GetFileNameWithoutExtension(FileUtilities.FixFilePath(itemSpec)); + } + } + + private static string ComputeExtension(string itemSpec) + { + // if the item-spec is a root directory, it can have no extension + if (IsRootDirectory(itemSpec)) + { + // NOTE: this is to prevent Path.GetExtension() from treating server and share elements in a UNC + // file-spec as filenames e.g. \\server.ext, \\server\share.ext + return string.Empty; + } + else + { + return Path.GetExtension(itemSpec); + } + } + + private static string ComputeRelativeDir(string itemSpec) + => FileUtilities.GetDirectory(itemSpec); + + private static string ComputeDirectory(string fullPath) + { + string directory = FileUtilities.GetDirectory(fullPath); + + if (NativeMethods.IsWindows) + { + int length; + + if (FileUtilitiesRegex.StartsWithDrivePattern(directory)) + { + length = 2; } else { - throw new InternalErrorException($"\"{modifier}\" is not a valid item-spec modifier."); + length = FileUtilitiesRegex.StartsWithUncPatternMatchLength(directory); } + + if (length != -1) + { + FrameworkErrorUtilities.VerifyThrow( + (directory.Length > length) && FileUtilities.IsSlash(directory[length]), + "Root directory must have a trailing slash."); + + return directory.Substring(length + 1); + } + + return directory; } - catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) - { - throw new InvalidOperationException(SR.FormatInvalidFilespecForTransform(modifier, itemSpec, e.Message)); - } - return modifiedItemSpec; + FrameworkErrorUtilities.VerifyThrow( + !string.IsNullOrEmpty(directory) && FileUtilities.IsSlash(directory[0]), + "Expected a full non-windows path rooted at '/'."); + + // A full unix path is always rooted at + // `/`, and a root-relative path is the + // rest of the string. + return directory.Substring(1); + } + + private static string ComputeModifiedTime(string itemSpec) + => TryGetFileInfo(itemSpec, out FileInfo? info) + ? info.LastWriteTime.ToString(FileUtilities.FileTimeFormat) + : string.Empty; + + private static string ComputeCreatedTime(string itemSpec) + => TryGetFileInfo(itemSpec, out FileInfo? info) + ? info.CreationTime.ToString(FileUtilities.FileTimeFormat) + : string.Empty; + + private static string ComputeAccessedTime(string itemSpec) + => TryGetFileInfo(itemSpec, out FileInfo? info) + ? info.LastAccessTime.ToString(FileUtilities.FileTimeFormat) + : string.Empty; + + private static bool TryGetFileInfo(string itemSpec, [NotNullWhen(true)] out FileInfo? result) + { + // About to go out to the file system. This means data is leaving the engine, so need to unescape first. + string unescapedItemSpec = EscapingUtilities.UnescapeAll(itemSpec); + + result = FileUtilities.GetFileInfoNoThrow(unescapedItemSpec); + return result is not null; } /// @@ -440,7 +677,7 @@ private static void ThrowForUrl(string fullPath, string itemSpec, string current if (fullPath.IndexOf(':') != fullPath.LastIndexOf(':')) { // Cause a better error to appear - fullPath = Path.GetFullPath(Path.Combine(currentDirectory, itemSpec)); + _ = Path.GetFullPath(Path.Combine(currentDirectory, itemSpec)); } } } diff --git a/src/Framework/Properties/AssemblyInfo.cs b/src/Framework/Properties/AssemblyInfo.cs index 80691b8860b..7473cdc18aa 100644 --- a/src/Framework/Properties/AssemblyInfo.cs +++ b/src/Framework/Properties/AssemblyInfo.cs @@ -55,6 +55,8 @@ [assembly: InternalsVisibleTo("Microsoft.Build.Tasks.UnitTests, PublicKey=002400000480000094000000060200000024000052534131000400000100010015c01ae1f50e8cc09ba9eac9147cf8fd9fce2cfe9f8dce4f7301c4132ca9fb50ce8cbf1df4dc18dd4d210e4345c744ecb3365ed327efdbc52603faa5e21daa11234c8c4a73e51f03bf192544581ebe107adee3a34928e39d04e524a9ce729d5090bfd7dad9d10c722c0def9ccc08ff0a03790e48bcd1f9b6c476063e1966a1c4")] [assembly: InternalsVisibleTo("Microsoft.Build.UnitTests.Shared, PublicKey=002400000480000094000000060200000024000052534131000400000100010015c01ae1f50e8cc09ba9eac9147cf8fd9fce2cfe9f8dce4f7301c4132ca9fb50ce8cbf1df4dc18dd4d210e4345c744ecb3365ed327efdbc52603faa5e21daa11234c8c4a73e51f03bf192544581ebe107adee3a34928e39d04e524a9ce729d5090bfd7dad9d10c722c0def9ccc08ff0a03790e48bcd1f9b6c476063e1966a1c4")] +[assembly: InternalsVisibleTo("MSBuild.Benchmarks, PublicKey=002400000480000094000000060200000024000052534131000400000100010007d1fa57c4aed9f0a32e84aa0faefd0de9e8fd6aec8f87fb03766c834c99921eb23be79ad9d5dcc1dd9ad236132102900b723cf980957fc4e177108fc607774f29e8320e92ea05ece4e821c0a5efe8f1645c4c0c93c1ab99285d622caa652c1dfad63d745d6f2de5f17e5eaf0fc4963d261c8a12436518206dc093344d5ad293")] + // This is the assembly-level GUID, and the GUID for the TypeLib associated with // this assembly. We should specify this explicitly, as opposed to letting // tlbexp just pick whatever it wants. diff --git a/src/MSBuild.Benchmarks/DefiningProjectModifiersBenchmark.cs b/src/MSBuild.Benchmarks/DefiningProjectModifiersBenchmark.cs new file mode 100644 index 00000000000..7cbc44be416 --- /dev/null +++ b/src/MSBuild.Benchmarks/DefiningProjectModifiersBenchmark.cs @@ -0,0 +1,271 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BenchmarkDotNet.Attributes; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace MSBuild.Benchmarks; + +[MemoryDiagnoser] +public class DefiningProjectModifiersBenchmark +{ + /// + /// Number of items per project file. + /// + private const int ItemsPerProject = 100; + + /// + /// Number of times each modifier is read per item, simulating repeated metadata access + /// during evaluation, task execution, etc. + /// + private const int RepeatedReads = 10; + + private string _tempDir = null!; + private ProjectInstance _singleProjectInstance = null!; + private ProjectInstance _multiProjectInstance = null!; + private TaskItem[] _taskItemsWithDefiningProject = null!; + + [GlobalSetup] + public void GlobalSetup() + { + _tempDir = Path.Combine(Path.GetTempPath(), "MSBuildBenchmarks", Guid.NewGuid().ToString("N")); + string srcDir = Path.Combine(_tempDir, "src"); + Directory.CreateDirectory(srcDir); + + // Create dummy files. + for (int i = 0; i < ItemsPerProject; i++) + { + File.WriteAllText(Path.Combine(srcDir, $"File{i}.cs"), string.Empty); + } + + // --- Single-project scenario --- + // All items defined in one project file. DefiningProjectFullPath is the same for all items, + // so a cache keyed by defining project path would hit on every item after the first. + using (var pc = new ProjectCollection()) + { + var root = Microsoft.Build.Construction.ProjectRootElement.Create(pc); + root.FullPath = Path.Combine(_tempDir, "SingleProject.csproj"); + + var itemGroup = root.AddItemGroup(); + for (int i = 0; i < ItemsPerProject; i++) + { + itemGroup.AddItem("Compile", Path.Combine(srcDir, $"File{i}.cs")); + } + + var project = new Project(root, null, null, pc); + _singleProjectInstance = project.CreateProjectInstance(); + } + + // --- Multi-project scenario --- + // Items imported from a second project file. The main project and the imported project + // each define items, so there are two distinct DefiningProjectFullPath values. + using (var pc = new ProjectCollection()) + { + // Imported project defines half the items. + var importRoot = Microsoft.Build.Construction.ProjectRootElement.Create(pc); + importRoot.FullPath = Path.Combine(_tempDir, "Imported.props"); + var importItemGroup = importRoot.AddItemGroup(); + for (int i = 0; i < ItemsPerProject / 2; i++) + { + importItemGroup.AddItem("Compile", Path.Combine(srcDir, $"File{i}.cs")); + } + + importRoot.Save(); + + // Main project imports the props file and defines the other half. + var mainRoot = Microsoft.Build.Construction.ProjectRootElement.Create(pc); + mainRoot.FullPath = Path.Combine(_tempDir, "MainProject.csproj"); + mainRoot.AddImport("Imported.props"); + var mainItemGroup = mainRoot.AddItemGroup(); + for (int i = ItemsPerProject / 2; i < ItemsPerProject; i++) + { + mainItemGroup.AddItem("Compile", Path.Combine(srcDir, $"File{i}.cs")); + } + + var project = new Project(mainRoot, null, null, pc); + _multiProjectInstance = project.CreateProjectInstance(); + } + + // --- TaskItem instances with defining project set --- + // Copy from ProjectItemInstance so that _definingProject is populated. + var sourceItems = _singleProjectInstance.GetItems("Compile").ToArray(); + _taskItemsWithDefiningProject = new TaskItem[sourceItems.Length]; + for (int i = 0; i < sourceItems.Length; i++) + { + _taskItemsWithDefiningProject[i] = new TaskItem(sourceItems[i]); + } + } + + [GlobalCleanup] + public void GlobalCleanup() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + + // ----------------------------------------------------------------------- + // ProjectItemInstance: Read all DefiningProject* modifiers on one item. + // Cold-cache baseline for all four DefiningProject modifiers. + // ----------------------------------------------------------------------- + + [Benchmark] + public string ProjectItemInstance_AllDefiningProjectModifiers_Once() + { + ProjectItemInstance item = _singleProjectInstance.GetItems("Compile").First(); + string last = null!; + + last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectFullPath); + last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectDirectory); + last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectName); + last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectExtension); + + return last; + } + + // ----------------------------------------------------------------------- + // ProjectItemInstance: Read DefiningProjectDirectory repeatedly. + // This is the most expensive DefiningProject modifier — it resolves + // FullPath, RootDir, and Directory internally. Repeated reads on the + // same item should benefit heavily from caching. + // ----------------------------------------------------------------------- + + [Benchmark] + public string ProjectItemInstance_DefiningProjectDirectory_Repeated() + { + ProjectItemInstance item = _singleProjectInstance.GetItems("Compile").First(); + string last = null!; + + for (int i = 0; i < RepeatedReads; i++) + { + last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectDirectory); + } + + return last; + } + + // ----------------------------------------------------------------------- + // ProjectItemInstance: Read DefiningProjectName + DefiningProjectExtension + // on all items from a single project. + // All items share the same defining project, so a per-defining-project + // cache should compute once and return cached results for the rest. + // ----------------------------------------------------------------------- + + [Benchmark] + public string ProjectItemInstance_DefiningProjectNameExtension_AllItems_SingleProject() + { + string last = null!; + + foreach (ProjectItemInstance item in _singleProjectInstance.GetItems("Compile")) + { + last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectName); + last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectExtension); + } + + return last; + } + + // ----------------------------------------------------------------------- + // ProjectItemInstance: Read DefiningProjectFullPath on all items from a + // multi-project scenario (main + import). + // Items come from two different defining projects, so a cache keyed by + // defining project path has two entries. + // ----------------------------------------------------------------------- + + [Benchmark] + public string ProjectItemInstance_DefiningProjectFullPath_AllItems_MultiProject() + { + string last = null!; + + foreach (ProjectItemInstance item in _multiProjectInstance.GetItems("Compile")) + { + last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectFullPath); + } + + return last; + } + + // ----------------------------------------------------------------------- + // ProjectItemInstance: Read DefiningProjectDirectory on all items from a + // multi-project scenario, repeated. + // The most expensive modifier across multiple passes — represents the + // worst case for uncached DefiningProject resolution. + // ----------------------------------------------------------------------- + + [Benchmark] + public string ProjectItemInstance_DefiningProjectDirectory_AllItems_MultiProject_Repeated() + { + string last = null!; + + for (int pass = 0; pass < RepeatedReads; pass++) + { + foreach (ProjectItemInstance item in _multiProjectInstance.GetItems("Compile")) + { + last = item.GetMetadataValue(ItemSpecModifiers.DefiningProjectDirectory); + } + } + + return last; + } + + // ----------------------------------------------------------------------- + // TaskItem: Read all DefiningProject* modifiers on one item. + // Exercises the Utilities.TaskItem → ItemSpecModifiers path with a + // defining project obtained by copying from a ProjectItemInstance. + // ----------------------------------------------------------------------- + + [Benchmark] + public string TaskItem_AllDefiningProjectModifiers_Once() + { + TaskItem item = _taskItemsWithDefiningProject[0]; + string last = null!; + + last = item.GetMetadata(ItemSpecModifiers.DefiningProjectFullPath); + last = item.GetMetadata(ItemSpecModifiers.DefiningProjectDirectory); + last = item.GetMetadata(ItemSpecModifiers.DefiningProjectName); + last = item.GetMetadata(ItemSpecModifiers.DefiningProjectExtension); + + return last; + } + + // ----------------------------------------------------------------------- + // TaskItem: Read DefiningProjectName + DefiningProjectExtension across + // all items. All share the same defining project path. + // ----------------------------------------------------------------------- + + [Benchmark] + public string TaskItem_DefiningProjectNameExtension_AllItems() + { + string last = null!; + + for (int i = 0; i < _taskItemsWithDefiningProject.Length; i++) + { + last = _taskItemsWithDefiningProject[i].GetMetadata(ItemSpecModifiers.DefiningProjectName); + last = _taskItemsWithDefiningProject[i].GetMetadata(ItemSpecModifiers.DefiningProjectExtension); + } + + return last; + } + + // ----------------------------------------------------------------------- + // TaskItem: Read DefiningProjectDirectory repeatedly on one item. + // ----------------------------------------------------------------------- + + [Benchmark] + public string TaskItem_DefiningProjectDirectory_Repeated() + { + TaskItem item = _taskItemsWithDefiningProject[0]; + string last = null!; + + for (int i = 0; i < RepeatedReads; i++) + { + last = item.GetMetadata(ItemSpecModifiers.DefiningProjectDirectory); + } + + return last; + } +} diff --git a/src/MSBuild.Benchmarks/Extensions.cs b/src/MSBuild.Benchmarks/Extensions.cs new file mode 100644 index 00000000000..c1f9ea8fe72 --- /dev/null +++ b/src/MSBuild.Benchmarks/Extensions.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BenchmarkDotNet.Reports; + +namespace MSBuild.Benchmarks; + +internal static class Extensions +{ + public static int ToExitCode(this IEnumerable summaries) + { + // an empty summary means that initial filtering and validation did not allow to run + if (!summaries.Any()) + { + return 1; + } + + // if anything has failed, it's an error + return summaries.Any(HasAnyErrors) ? 1 : 0; + } + + public static bool HasAnyErrors(this Summary summary) + => summary.HasCriticalValidationErrors || summary.Reports.Any(report => report.HasAnyErrors()); + + public static bool HasAnyErrors(this BenchmarkReport report) + => !report.BuildResult.IsBuildSuccess || !report.AllMeasurements.Any(); +} diff --git a/src/MSBuild.Benchmarks/ItemSpecModifiersBenchmark.cs b/src/MSBuild.Benchmarks/ItemSpecModifiersBenchmark.cs new file mode 100644 index 00000000000..5c34dbcbb59 --- /dev/null +++ b/src/MSBuild.Benchmarks/ItemSpecModifiersBenchmark.cs @@ -0,0 +1,115 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BenchmarkDotNet.Attributes; +using Microsoft.Build.Framework; + +namespace MSBuild.Benchmarks; + +[MemoryDiagnoser] +public class ItemSpecModifiersBenchmark +{ + private string _currentDirectory = null!; + private string _itemSpec = null!; + private string _definingProjectEscaped = null!; + private string _recursiveDirModifier = null!; + + [GlobalSetup] + public void GlobalSetup() + { + _currentDirectory = Path.GetTempPath(); + _itemSpec = Path.Combine(_currentDirectory, "src", "Framework", "ItemSpecModifiers.cs"); + _definingProjectEscaped = Path.Combine(_currentDirectory, "src", "Framework", "Microsoft.Build.Framework.csproj"); + + // Ensure the file exists so time-based modifiers can resolve. + Directory.CreateDirectory(Path.GetDirectoryName(_itemSpec)!); + if (!File.Exists(_itemSpec)) + { + File.WriteAllText(_itemSpec, string.Empty); + } + + _recursiveDirModifier = ItemSpecModifiers.RecursiveDir; + } + + [GlobalCleanup] + public void GlobalCleanup() + { + if (File.Exists(_itemSpec)) + { + File.Delete(_itemSpec); + } + } + + // ----------------------------------------------------------------------- + // FrozenSet lookup – covers IsItemSpecModifier for all known modifiers + // plus a miss. + // ----------------------------------------------------------------------- + + [Benchmark] + public int IsItemSpecModifier_AllModifiers() + { + int count = 0; + foreach (string modifier in ItemSpecModifiers.All) + { + if (ItemSpecModifiers.IsItemSpecModifier(modifier)) + { + count++; + } + } + + // Also check a miss. + if (ItemSpecModifiers.IsItemSpecModifier("SomeCustomMetadata")) + { + count++; + } + + return count; + } + + // ----------------------------------------------------------------------- + // IsDerivableItemSpecModifier – RecursiveDir is the only modifier that + // hits the length+char guard returning false. + // ----------------------------------------------------------------------- + + [Benchmark] + public bool IsDerivableItemSpecModifier_RecursiveDir() + => ItemSpecModifiers.IsDerivableItemSpecModifier(_recursiveDirModifier); + + // ----------------------------------------------------------------------- + // GetItemSpecModifier – FullPath is the most commonly used modifier and + // the baseline for the caching benchmark below. + // ----------------------------------------------------------------------- + + [Benchmark] + public string GetItemSpecModifier_FullPath() + => ItemSpecModifiers.GetItemSpecModifier(_itemSpec, ItemSpecModifiers.FullPath, _currentDirectory, _definingProjectEscaped); + + // ----------------------------------------------------------------------- + // GetItemSpecModifier – Directory has the most complex logic: resolves + // FullPath internally, strips the root, and differs by OS. + // ----------------------------------------------------------------------- + + [Benchmark] + public string GetItemSpecModifier_Directory() + => ItemSpecModifiers.GetItemSpecModifier(_itemSpec, ItemSpecModifiers.Directory, _currentDirectory, _definingProjectEscaped); + + // ----------------------------------------------------------------------- + // GetItemSpecModifier – file-time modifier (I/O-bound). All three time + // modifiers share the same code shape; ModifiedTime uses + // FileUtilities.GetFileInfoNoThrow which is the most common path. + // ----------------------------------------------------------------------- + + [Benchmark] + public string GetItemSpecModifier_ModifiedTime() + => ItemSpecModifiers.GetItemSpecModifier(_itemSpec, ItemSpecModifiers.ModifiedTime, _currentDirectory, _definingProjectEscaped); + + // ----------------------------------------------------------------------- + // GetItemSpecModifier – DefiningProjectDirectory is the most expensive + // defining-project modifier: it recursively resolves both RootDir and + // Directory on the defining project path. + // ----------------------------------------------------------------------- + + [Benchmark] + public string GetItemSpecModifier_DefiningProjectDirectory() + => ItemSpecModifiers.GetItemSpecModifier(_itemSpec, ItemSpecModifiers.DefiningProjectDirectory, _currentDirectory, _definingProjectEscaped); +} diff --git a/src/MSBuild.Benchmarks/ItemSpecModifiersCachingBenchmark.cs b/src/MSBuild.Benchmarks/ItemSpecModifiersCachingBenchmark.cs new file mode 100644 index 00000000000..e1a452257f9 --- /dev/null +++ b/src/MSBuild.Benchmarks/ItemSpecModifiersCachingBenchmark.cs @@ -0,0 +1,220 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BenchmarkDotNet.Attributes; +using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace MSBuild.Benchmarks; + +[MemoryDiagnoser] +public class ItemSpecModifiersCachingBenchmark +{ + /// + /// Number of items to create for the multi-item benchmarks. + /// + private const int ItemCount = 200; + + /// + /// Number of times each modifier is read per item, simulating repeated metadata access + /// during evaluation, task execution, etc. + /// + private const int RepeatedReads = 10; + + private string _tempDir = null!; + private TaskItem[] _taskItems = null!; + private ProjectInstance _projectInstance = null!; + + [GlobalSetup] + public void GlobalSetup() + { + _tempDir = Path.Combine(Path.GetTempPath(), "MSBuildBenchmarks", Guid.NewGuid().ToString("N")); + string srcDir = Path.Combine(_tempDir, "src", "Framework"); + Directory.CreateDirectory(srcDir); + + // Create TaskItem instances with realistic file paths. + _taskItems = new TaskItem[ItemCount]; + for (int i = 0; i < ItemCount; i++) + { + string filePath = Path.Combine(srcDir, $"File{i}.cs"); + File.WriteAllText(filePath, string.Empty); + _taskItems[i] = new TaskItem(filePath); + } + + // Create a ProjectInstance with the same items for the ProjectItemInstance benchmarks. + using var projectCollection = new ProjectCollection(); + var root = Microsoft.Build.Construction.ProjectRootElement.Create(projectCollection); + root.FullPath = Path.Combine(_tempDir, "Test.csproj"); + + var itemGroup = root.AddItemGroup(); + for (int i = 0; i < ItemCount; i++) + { + itemGroup.AddItem("Compile", Path.Combine(srcDir, $"File{i}.cs")); + } + + var project = new Project(root, null, null, projectCollection); + _projectInstance = project.CreateProjectInstance(); + } + + [GlobalCleanup] + public void GlobalCleanup() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + + // ----------------------------------------------------------------------- + // TaskItem: Read all derivable modifiers on one item once. + // This is the cold-cache baseline — every modifier must be computed. + // ----------------------------------------------------------------------- + + [Benchmark] + public string TaskItem_AllDerivableModifiers_Once() + { + TaskItem item = _taskItems[0]; + string last = null!; + + last = item.GetMetadata(ItemSpecModifiers.FullPath); + last = item.GetMetadata(ItemSpecModifiers.RootDir); + last = item.GetMetadata(ItemSpecModifiers.Filename); + last = item.GetMetadata(ItemSpecModifiers.Extension); + last = item.GetMetadata(ItemSpecModifiers.RelativeDir); + last = item.GetMetadata(ItemSpecModifiers.Directory); + last = item.GetMetadata(ItemSpecModifiers.Identity); + + return last; + } + + // ----------------------------------------------------------------------- + // TaskItem: Read Filename + Extension repeatedly on one item. + // This is the hot-path pattern — tasks reading the same metadata many + // times on the same item. The cache should make reads 2..N near-free. + // ----------------------------------------------------------------------- + + [Benchmark] + public string TaskItem_FilenameAndExtension_Repeated() + { + TaskItem item = _taskItems[0]; + string last = null!; + + for (int i = 0; i < RepeatedReads; i++) + { + last = item.GetMetadata(ItemSpecModifiers.Filename); + last = item.GetMetadata(ItemSpecModifiers.Extension); + } + + return last; + } + + // ----------------------------------------------------------------------- + // TaskItem: Read Filename across many items. + // Simulates a task iterating all items and reading %(Filename) on each. + // First read per item populates the cache; this measures the amortized + // cost including the initial computation. + // ----------------------------------------------------------------------- + + [Benchmark] + public string TaskItem_Filename_ManyItems() + { + string last = null!; + + for (int i = 0; i < _taskItems.Length; i++) + { + last = _taskItems[i].GetMetadata(ItemSpecModifiers.Filename); + } + + return last; + } + + // ----------------------------------------------------------------------- + // TaskItem: Read FullPath + Directory + RootDir repeatedly on one item. + // Directory and RootDir both depend on FullPath internally, so the cache + // should eliminate redundant Path.GetFullPath calls after the first read. + // ----------------------------------------------------------------------- + + [Benchmark] + public string TaskItem_FullPathDerivedModifiers_Repeated() + { + TaskItem item = _taskItems[0]; + string last = null!; + + for (int i = 0; i < RepeatedReads; i++) + { + last = item.GetMetadata(ItemSpecModifiers.FullPath); + last = item.GetMetadata(ItemSpecModifiers.RootDir); + last = item.GetMetadata(ItemSpecModifiers.Directory); + } + + return last; + } + + // ----------------------------------------------------------------------- + // ProjectItemInstance: Read all derivable modifiers once. + // Exercises the ProjectItemInstance.TaskItem → BuiltInMetadata → + // ItemSpecModifiers.GetItemSpecModifier(ref CachedItemSpecModifiers) path. + // ----------------------------------------------------------------------- + + [Benchmark] + public string ProjectItemInstance_AllDerivableModifiers_Once() + { + ProjectItemInstance item = _projectInstance.GetItems("Compile").First(); + string last = null!; + + last = item.GetMetadataValue(ItemSpecModifiers.FullPath); + last = item.GetMetadataValue(ItemSpecModifiers.RootDir); + last = item.GetMetadataValue(ItemSpecModifiers.Filename); + last = item.GetMetadataValue(ItemSpecModifiers.Extension); + last = item.GetMetadataValue(ItemSpecModifiers.RelativeDir); + last = item.GetMetadataValue(ItemSpecModifiers.Directory); + last = item.GetMetadataValue(ItemSpecModifiers.Identity); + + return last; + } + + // ----------------------------------------------------------------------- + // ProjectItemInstance: Read Filename + Extension on all items. + // The dominant real-world pattern — iterating all Compile items and + // reading %(Filename)%(Extension) for output path computation. + // ----------------------------------------------------------------------- + + [Benchmark] + public string ProjectItemInstance_FilenameExtension_AllItems() + { + string last = null!; + + foreach (ProjectItemInstance item in _projectInstance.GetItems("Compile")) + { + last = item.GetMetadataValue(ItemSpecModifiers.Filename); + last = item.GetMetadataValue(ItemSpecModifiers.Extension); + } + + return last; + } + + // ----------------------------------------------------------------------- + // ProjectItemInstance: Read Filename + Extension on all items, repeated. + // Simulates multiple targets or tasks reading the same metadata from + // the same evaluated items during a single build. + // ----------------------------------------------------------------------- + + [Benchmark] + public string ProjectItemInstance_FilenameExtension_AllItems_Repeated() + { + string last = null!; + + for (int pass = 0; pass < RepeatedReads; pass++) + { + foreach (ProjectItemInstance item in _projectInstance.GetItems("Compile")) + { + last = item.GetMetadataValue(ItemSpecModifiers.Filename); + last = item.GetMetadataValue(ItemSpecModifiers.Extension); + } + } + + return last; + } +} diff --git a/src/MSBuild.Benchmarks/MSBuild.Benchmarks.csproj b/src/MSBuild.Benchmarks/MSBuild.Benchmarks.csproj new file mode 100644 index 00000000000..1f2f22d058e --- /dev/null +++ b/src/MSBuild.Benchmarks/MSBuild.Benchmarks.csproj @@ -0,0 +1,29 @@ + + + + Exe + false + $(RuntimeOutputTargetFrameworks) + $(RuntimeOutputPlatformTarget) + + false + enable + + + + + + + + + + + + + + + + + + + diff --git a/src/MSBuild.Benchmarks/Program.cs b/src/MSBuild.Benchmarks/Program.cs new file mode 100644 index 00000000000..0fd6fdb93b0 --- /dev/null +++ b/src/MSBuild.Benchmarks/Program.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnostics.Windows; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Running; +using static MSBuild.Benchmarks.Extensions; + +var argList = new List(args); + +ParseAndRemoveBooleanParameter(argList, "--collect-etw", out bool collectEtw); +ParseAndRemoveBooleanParameter(argList, "--disable-ngen", out bool disableNGen); +ParseAndRemoveBooleanParameter(argList, "--disable-inlining", out bool disableJitInlining); + +return BenchmarkSwitcher + .FromAssembly(typeof(Program).Assembly) + .Run([.. argList], GetConfig(collectEtw, disableNGen, disableJitInlining)) + .ToExitCode(); + +static IConfig GetConfig(bool collectEtw, bool disableNGen, bool disableJitInlining) +{ + if (Debugger.IsAttached) + { + return new DebugInProcessConfig(); + } + + IConfig config = DefaultConfig.Instance; + + if (collectEtw) + { + config = config.AddDiagnoser(new EtwProfiler()); + } + + // Use a mutator for settings that should apply to all jobs + // (default or CLI-specified like --job short). + Job overrides = new Job() + .DontEnforcePowerPlan(); + + if (disableNGen) + { + overrides = overrides + .WithEnvironmentVariable("COMPlus_ZapDisable", "1") + .WithEnvironmentVariable("COMPlus_ReadyToRun", "0") + .WithEnvironmentVariable("DOTNET_ReadyToRun", "0"); + } + + if (disableJitInlining) + { + overrides = overrides + .WithEnvironmentVariable("COMPlus_JitNoInline", "1") + .WithEnvironmentVariable("DOTNET_JitNoInline", "1"); + } + + config = config.AddJob(overrides.AsMutator()); + + return config; +} + +static void ParseAndRemoveBooleanParameter(List argsList, string parameter, out bool parameterValue) +{ + int parameterIndex = argsList.IndexOf(parameter); + + if (parameterIndex != -1) + { + argsList.RemoveAt(parameterIndex); + + parameterValue = true; + } + else + { + parameterValue = false; + } +} diff --git a/src/MSBuild.Benchmarks/readme.md b/src/MSBuild.Benchmarks/readme.md new file mode 100644 index 00000000000..e21364acf2f --- /dev/null +++ b/src/MSBuild.Benchmarks/readme.md @@ -0,0 +1,45 @@ +# MSBuild Benchmarks + +This project contains performance benchmarks for MSBuild using [BenchmarkDotNet](https://benchmarkdotnet.org/). + +## Running Benchmarks + +### Run All Benchmarks + +``` +cd src/MSBuild.Benchmarks +dotnet run -c Release +``` + +### Run Benchmarks on a Specific TFM + +``` +cd src/MSBuild.Benchmarks +dotnet run -c Release -f net472 +dotnet run -c Release -f net10.0 +``` + +### Filter to a Specific Benchmark Class + +``` +dotnet run -c Release -f net10.0 -- --filter "*ItemSpecModifiersBenchmark*" +``` + +### Filter to a Single Benchmark Method + +``` +dotnet run -c Release -f net10.0 -- --filter "*ItemSpecModifiersBenchmark.IncludeOnly" +``` +## Command-Line Options + +### Custom Options + +- `--collect-etw` - Enable ETW (Event Tracing for Windows) profiling diagnostics +- `--disable-ngen` - Disable NGEN/ReadyToRun to measure pure JIT performance +- `--disable-inlining` - Disable JIT inlining for more accurate method-level profiling + +These custom options can be combined with any BenchmarkDotNet options: + +``` +dotnet run -c Release -f net10.0 -- --filter "*ItemSpecModifiersBenchmark*" --job short --disable-ngen +``` diff --git a/src/Shared/TaskParameter.cs b/src/Shared/TaskParameter.cs index b132424ddac..5fb6aff6ad9 100644 --- a/src/Shared/TaskParameter.cs +++ b/src/Shared/TaskParameter.cs @@ -560,9 +560,9 @@ private class TaskParameterTaskItem : private Dictionary _customEscapedMetadata = null; /// - /// Cache for fullpath metadata + /// Cache for derivable modifier values /// - private string _fullPath; + private ItemSpecModifiers.Cache _cachedModifiers; /// /// Constructor for serialization @@ -669,7 +669,11 @@ public ICollection MetadataNames get { List metadataNames = (_customEscapedMetadata == null) ? new List() : new List(_customEscapedMetadata.Keys); - metadataNames.AddRange(ItemSpecModifiers.All); + + foreach (string name in ItemSpecModifiers.All) + { + metadataNames.Add(name); + } return metadataNames; } @@ -850,22 +854,19 @@ public override object InitializeLifetimeService() /// string ITaskItem2.GetMetadataValueEscaped(string metadataName) { - ErrorUtilities.VerifyThrowArgumentNull(metadataName); - - string metadataValue = null; + ArgumentNullException.ThrowIfNull(metadataName); - if (ItemSpecModifiers.IsDerivableItemSpecModifier(metadataName)) + if (ItemSpecModifiers.TryGetDerivableModifierKind(metadataName, out ItemSpecModifierKind modifierKind)) { // FileUtilities.GetItemSpecModifier is expecting escaped data, which we assume we already are. // Passing in a null for currentDirectory indicates we are already in the correct current directory - metadataValue = ItemSpecModifiers.GetItemSpecModifier(null, _escapedItemSpec, _escapedDefiningProject, metadataName, ref _fullPath); - } - else if (_customEscapedMetadata != null) - { - _customEscapedMetadata.TryGetValue(metadataName, out metadataValue); + return ItemSpecModifiers.GetItemSpecModifier(_escapedItemSpec, modifierKind, null, _escapedDefiningProject, ref _cachedModifiers); } - return metadataValue ?? String.Empty; + string metadataValue = null; + _customEscapedMetadata?.TryGetValue(metadataName, out metadataValue); + + return metadataValue ?? string.Empty; } /// diff --git a/src/Utilities/TaskItem.cs b/src/Utilities/TaskItem.cs index 2cfb2f9d97b..8d0da54927c 100644 --- a/src/Utilities/TaskItem.cs +++ b/src/Utilities/TaskItem.cs @@ -51,8 +51,8 @@ public sealed class TaskItem : // Values are stored in escaped form. private ImmutableDictionary _metadata; - // cache of the fullpath value - private string _fullPath; + // cache of derivable modifier values + private ItemSpecModifiers.Cache _cachedModifiers; /// /// May be defined if we're copying this item from a pre-existing one. Otherwise, @@ -186,7 +186,7 @@ public string ItemSpec ErrorUtilities.VerifyThrowArgumentNull(value, nameof(ItemSpec)); _itemSpec = FileUtilities.FixFilePath(value); - _fullPath = null; + _cachedModifiers.Clear(); } } @@ -205,7 +205,7 @@ string ITaskItem2.EvaluatedIncludeEscaped set { _itemSpec = FileUtilities.FixFilePath(value); - _fullPath = null; + _cachedModifiers.Clear(); } } @@ -226,7 +226,10 @@ public ICollection MetadataNames metadataNames.AddRange(_metadata.Keys); } - metadataNames.AddRange(ItemSpecModifiers.All); + foreach (string name in ItemSpecModifiers.All) + { + metadataNames.Add(name); + } return metadataNames; } @@ -500,21 +503,18 @@ public static explicit operator string(TaskItem taskItemToCast) /// string ITaskItem2.GetMetadataValueEscaped(string metadataName) { - ErrorUtilities.VerifyThrowArgumentNull(metadataName); - - string metadataValue = null; + ArgumentNullException.ThrowIfNull(metadataName); - if (ItemSpecModifiers.IsDerivableItemSpecModifier(metadataName)) + if (ItemSpecModifiers.TryGetDerivableModifierKind(metadataName, out ItemSpecModifierKind modifierKind)) { // FileUtilities.GetItemSpecModifier is expecting escaped data, which we assume we already are. // Passing in a null for currentDirectory indicates we are already in the correct current directory - metadataValue = ItemSpecModifiers.GetItemSpecModifier(null, _itemSpec, _definingProject, metadataName, ref _fullPath); - } - else - { - _metadata?.TryGetValue(metadataName, out metadataValue); + return ItemSpecModifiers.GetItemSpecModifier(_itemSpec, modifierKind, null, _definingProject, ref _cachedModifiers); } + string metadataValue = null; + _metadata?.TryGetValue(metadataName, out metadataValue); + return metadataValue ?? string.Empty; }