Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimize Match-on-metadata #6035

Merged
merged 17 commits into from Feb 24, 2021
Expand Up @@ -249,7 +249,18 @@ private void ExecuteRemove(ProjectItemGroupTaskItemInstance child, ItemBucket bu
}
else
{
itemsToRemove = FindItemsUsingMatchOnMetadata(group, child, bucket, matchOnMetadata, matchingOptions);
ImmutableList<string> metadataList = matchOnMetadata.ToImmutableList();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd keep this as a separate method to have ExecuteRemove read nicer by only contain top level intent and delegating more nitty gritty implementation details to helper methods. Like the other if branch.

Copy link
Member

@ladipro ladipro Jan 15, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the conversion to ImmutableList<>? You should be able to make the MetadataSet<,> constructor take IEnumerable<>.

ItemSpec<ProjectPropertyInstance, ProjectItemInstance> itemSpec = new(child.Remove, bucket.Expander, child.RemoveLocation, Project.Directory, true);
ProjectFileErrorUtilities.VerifyThrowInvalidProjectFile(
itemSpec.Fragments.Count == 1
&& itemSpec.Fragments.First() is ItemSpec<ProjectPropertyInstance, ProjectItemInstance>.ItemExpressionFragment
&& matchOnMetadata.Count == 1,
new BuildEventFileInfo(string.Empty),
"OM_MatchOnMetadataIsRestrictedToOnlyOneReferencedItem",
child.RemoveLocation,
child.Remove);
MetadataSet<ProjectPropertyInstance, ProjectItemInstance> metadataSet = new(matchingOptions, metadataList, itemSpec);
itemsToRemove = group.Where(item => metadataSet.Contains(metadataList.Select(m => item.GetMetadataValue(m)))).ToList();
}

if (itemsToRemove != null)
Expand All @@ -268,29 +279,6 @@ private void ExecuteRemove(ProjectItemGroupTaskItemInstance child, ItemBucket bu
}
}

private List<ProjectItemInstance> FindItemsUsingMatchOnMetadata(
ICollection<ProjectItemInstance> items,
ProjectItemGroupTaskItemInstance child,
ItemBucket bucket,
HashSet<string> matchOnMetadata,
MatchOnMetadataOptions options)
{
ErrorUtilities.VerifyThrowArgumentNull(matchOnMetadata, nameof(matchOnMetadata));

var itemSpec = new ItemSpec<ProjectPropertyInstance, ProjectItemInstance>(child.Remove, bucket.Expander, child.RemoveLocation, Project.Directory, true);

ProjectFileErrorUtilities.VerifyThrowInvalidProjectFile(
itemSpec.Fragments.Count == 1
&& itemSpec.Fragments.First() is ItemSpec<ProjectPropertyInstance, ProjectItemInstance>.ItemExpressionFragment
&& matchOnMetadata.Count == 1,
new BuildEventFileInfo(string.Empty),
"OM_MatchOnMetadataIsRestrictedToOnlyOneReferencedItem",
child.RemoveLocation,
child.Remove);

return items.Where(item => itemSpec.MatchesItemOnMetadata(item, matchOnMetadata, options)).ToList();
}

/// <summary>
/// Modifies items in the world - specifically, changes their metadata. Changes to items that are part of the project manifest are backed up, so
/// they can be reverted when the project is reset after the end of the build.
Expand Down
121 changes: 75 additions & 46 deletions src/Build/Evaluation/ItemSpec.cs
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.Build.Globbing;
using Microsoft.Build.Internal;
Expand Down Expand Up @@ -85,24 +86,6 @@ public override bool IsMatch(string itemToMatch)
return ReferencedItems.Any(v => v.ItemAsValueFragment.IsMatch(itemToMatch));
}

public override bool IsMatchOnMetadata(IItem item, IEnumerable<string> metadata, MatchOnMetadataOptions options)
{
return ReferencedItems.Any(referencedItem =>
metadata.All(m => !item.GetMetadataValue(m).Equals(string.Empty) && MetadataComparer(options, item.GetMetadataValue(m), referencedItem.Item.GetMetadataValue(m))));
}

private bool MetadataComparer(MatchOnMetadataOptions options, string itemMetadata, string referencedItemMetadata)
{
if (options.Equals(MatchOnMetadataOptions.PathLike))
{
return FileUtilities.ComparePathsNoThrow(itemMetadata, referencedItemMetadata, ProjectDirectory);
}
else
{
return String.Equals(itemMetadata, referencedItemMetadata, options.Equals(MatchOnMetadataOptions.CaseInsensitive) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}
}

public override IMSBuildGlob ToMSBuildGlob()
{
return MsBuildGlob;
Expand Down Expand Up @@ -310,26 +293,6 @@ public bool MatchesItem(I item)
return false;
}

/// <summary>
/// Return true if any of the given <paramref name="metadata" /> matches the metadata on <paramref name="item" />
/// </summary>
/// <param name="item">The item to attempt to find a match for based on matching metadata</param>
/// <param name="metadata">Names of metadata to look for matches for</param>
/// <param name="options">metadata option matching</param>
/// <returns></returns>
public bool MatchesItemOnMetadata(IItem item, IEnumerable<string> metadata, MatchOnMetadataOptions options)
{
foreach (var fragment in Fragments)
{
if (fragment.IsMatchOnMetadata(item, metadata, options))
{
return true;
}
}

return false;
}

/// <summary>
/// Return the fragments that match against the given <paramref name="itemToMatch" />
/// </summary>
Expand Down Expand Up @@ -456,14 +419,6 @@ public virtual bool IsMatch(string itemToMatch)
return FileMatcher.IsMatch(itemToMatch);
}

/// <summary>
/// Returns true if <paramref name="itemToMatch" /> matches any ReferencedItems based on <paramref name="metadata" /> and <paramref name="options" />.
/// </summary>
public virtual bool IsMatchOnMetadata(IItem itemToMatch, IEnumerable<string> metadata, MatchOnMetadataOptions options)
{
return false;
}

public virtual IMSBuildGlob ToMSBuildGlob()
{
return MsBuildGlob;
Expand Down Expand Up @@ -504,4 +459,78 @@ public GlobFragment(string textFragment, string projectDirectory)
&& TextFragment[2] == '*'
&& FileUtilities.IsAnySlash(TextFragment[3]);
}

internal class MetadataSet<P, I> where P : class, IProperty where I : class, IItem, IMetadataTable
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super-nit: The class can be sealed.

{
private Dictionary<string, MetadataSet<P, I>> children;
Func<string, string> normalize;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Names missing leading underscores, both fields can be readonly.


internal MetadataSet(MatchOnMetadataOptions options, ImmutableList<string> metadata, ItemSpec<P, I> itemSpec)
{
StringComparer comparer = options == MatchOnMetadataOptions.CaseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase;
children = new Dictionary<string, MetadataSet<P, I>>(comparer);
normalize = options == MatchOnMetadataOptions.PathLike ? p => FileUtilities.NormalizePathForComparisonNoThrow(p, Environment.CurrentDirectory) : p => p;
foreach (ItemSpec<P, I>.ItemExpressionFragment frag in itemSpec.Fragments)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this type check, isn't ItemSpec.Fragments a list of the base class ItemSpecFragment? (List<ItemSpecFragment>)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. It might be casting it automatically when it sees that, but I'm not sure. I don't think we should care about other kinds of fragments, since I don't think they can have metadata, so I think this is ok, but that was a lot of "I think"s.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as whether this is actually a safe, I should have mentioned that the type is checked before the MetadataSet is constructed, so it should always be correct.

{
foreach (ItemSpec<P, I>.ReferencedItem referencedItem in frag.ReferencedItems)
{
this.Add(metadata.Select(m => referencedItem.Item.GetMetadataValue(m)), comparer);
}
}
}

private MetadataSet(StringComparer comparer)
{
children = new Dictionary<string, MetadataSet<P, I>>(comparer);
}

// Relies on IEnumerable returning the metadata in a reasonable order. Reasonable?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The question is whether the matching IEnumerable<> is passed to the constructor and to Contains. It appears to be true although I wonder if there's a way to enforce it in the code.

private void Add(IEnumerable<string> metadata, StringComparer comparer)
{
MetadataSet<P, I> current = this;
foreach (string m in metadata)
{
string normalizedString = normalize(m);
if (current.children.TryGetValue(normalizedString, out MetadataSet<P, I> child))
{
current = child;
}
else
{
current.children.Add(normalizedString, new MetadataSet<P, I>(comparer));
current = current.children[normalizedString];
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Redundant dictionary lookup.

Suggested change
if (current.children.TryGetValue(normalizedString, out MetadataSet<P, I> child))
{
current = child;
}
else
{
current.children.Add(normalizedString, new MetadataSet<P, I>(comparer));
current = current.children[normalizedString];
}
if (!current.children.TryGetValue(normalizedString, out MetadataSet<P, I> child))
{
child = new MetadataSet<P, I>(comparer);
current.children.Add(normalizedString, child);
}
current = child;

}
}

internal bool Contains(IEnumerable<string> metadata)
{
bool nonEmptyFound = false;
MetadataSet<P, I> curr = this;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super-nit: current to match the name used in Add?

foreach (string m in metadata)
{
if (!String.IsNullOrEmpty(m))
{
nonEmptyFound = true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isn't this return false? The previous code was checking if all metadata is defined and their value matches. I suggest adding test coverage for the case where m is empty.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From #4997:
if a metadata name doesn't exist in both items, then it's considered a match. Special case: if none of the metadata exist in both items, then it should not match.

But you're right—the code I'm replacing checks for string.Empty and automatically returns false. Given our conversation in the PR review meeting, I'll change to that. Thanks!

}
if (!curr.children.TryGetValue(normalize(m), out curr))
{
return false;
}
}
return nonEmptyFound;
}
}

public enum MatchOnMetadataOptions
{
CaseSensitive,
CaseInsensitive,
PathLike
}

public static class MatchOnMetadataConstants
{
public const MatchOnMetadataOptions MatchOnMetadataOptionsDefaultValue = MatchOnMetadataOptions.CaseSensitive;
}
}
24 changes: 12 additions & 12 deletions src/Build/Evaluation/LazyItemEvaluator.RemoveOperation.cs
Expand Up @@ -14,6 +14,7 @@ class RemoveOperation : LazyItemOperation
{
readonly ImmutableList<string> _matchOnMetadata;
readonly MatchOnMetadataOptions _matchOnMetadataOptions;
private MetadataSet<P, I> metadataSet;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Naming convention, field name is missing the leading underscore.


public RemoveOperation(RemoveOperationBuilder builder, LazyItemEvaluator<P, I, M, D> lazyEvaluator)
: base(builder, lazyEvaluator)
Expand Down Expand Up @@ -55,13 +56,23 @@ protected override ImmutableList<I> SelectItems(ImmutableList<ItemData>.Builder
var items = ImmutableHashSet.CreateBuilder<I>();
foreach (ItemData item in listBuilder)
{
if (_matchOnMetadata.IsEmpty ? _itemSpec.MatchesItem(item.Item) : _itemSpec.MatchesItemOnMetadata(item.Item, _matchOnMetadata, _matchOnMetadataOptions))
if (_matchOnMetadata.IsEmpty ? _itemSpec.MatchesItem(item.Item) : MatchesItemOnMetadata(item.Item))
items.Add(item.Item);
}

return items.ToImmutableList();
}

private bool MatchesItemOnMetadata(I item)
{
if (metadataSet == null)
Forgind marked this conversation as resolved.
Show resolved Hide resolved
{
metadataSet = new MetadataSet<P, I>(_matchOnMetadataOptions, _matchOnMetadata, _itemSpec);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This lazy initialization of metadataSet is not thread safe. Is it guaranteed to always run only on one thread?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can put it in the constructor—shouldn't affect perf, assuming operations are always executed.

}

return metadataSet.Contains(_matchOnMetadata.Select(m => item.GetMetadataValue(m)));
}

protected override void SaveItems(ImmutableList<I> items, ImmutableList<ItemData>.Builder listBuilder)
{
if (!_conditionResult)
Expand Down Expand Up @@ -100,15 +111,4 @@ public RemoveOperationBuilder(ProjectItemElement itemElement, bool conditionResu
}
}
}

public enum MatchOnMetadataOptions
{
CaseSensitive,
CaseInsensitive,
PathLike
}

public static class MatchOnMetadataConstants {
public const MatchOnMetadataOptions MatchOnMetadataOptionsDefaultValue = MatchOnMetadataOptions.CaseSensitive;
}
}