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

Eval cond perf #6859

Merged
merged 10 commits into from
Oct 26, 2021
Merged

Eval cond perf #6859

merged 10 commits into from
Oct 26, 2021

Conversation

Forgind
Copy link
Member

@Forgind Forgind commented Sep 15, 2021

This incorporates several changes to try to speed up the average time it takes to evaluate conditions. Specifically, it attempts to avoid repeat computations by combining Can...Evaluate and ...Evaluate into Try...Evaluate, only checks whether something is empty once each, and exits early if it's clearly empty or not empty. Look at this commit-by-commit! Also note that commits 2+3 should be combined, commits 4 and 7 cancel each other out, and commits 5+8 should be combined. I also will need to add comments for the new Try*Evaluate methods, but I think this is otherwise ready.

Edit: squashed this down to 3 cleaner commits.

Testing

I tried building OrchardCore before and after this change. EvaluateCondition seemed to take about 10% of total Evaluate time before, and this shaved off about a third of that.

Notes

@Forgind
Copy link
Member Author

Forgind commented Sep 17, 2021

I'm looking at the last test failure, and it seems to be for a condition like:
('a'=='a')==a

The first part of that evaluates fine, but the second part doesn't evaluate and is supposed to throw an "improperly constructed" exception.

This change skips over evaluating the second properly because it recognizes it as a non-bool and says they aren't equal. I can change it to maintain exact behavior of what we had before, but this doesn't seem particularly worse to me. Thoughts?

Comment on lines 69 to 71
double? rightNumericValue = RightChild.TryNumericEvaluate(state);
if (rightNumericValue is not null)
{
return Compare(leftNumericValue.Value, rightNumericValue);
}
Copy link
Member Author

@Forgind Forgind Sep 17, 2021

Choose a reason for hiding this comment

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

I don't know if there are other cases similar to MSBuildToolsVersion. I would guess not, at a glance. Maybe make this:

Suggested change
double? rightNumericValue = RightChild.TryNumericEvaluate(state);
if (rightNumericValue is not null)
{
return Compare(leftNumericValue.Value, rightNumericValue);
}
double? rightNumericValue = RightChild.TryNumericEvaluate(state);
if (rightNumericValue is not null)
{
return Compare(leftNumericValue.Value, rightNumericValue);
}
else if (!LeftChild.DebuggerDisplay.Equals("$(MSBuildToolsVersion)")
{
return false;
}

@benvillalobos
Copy link
Member

This change skips over evaluating the second properly because it recognizes it as a non-bool and says they aren't equal.

It'll be harder to get this PR in if it's meant for perf but we've changed something observable to customers (outside of faster builds).

What if we consider this PR a perf improvement and modify it a bit for #6277? It sounds like this PR could set the stage for that other issue.

Comment on lines 139 to 140
double? returnValue = TryConvertDecimalOrHexToDouble(number);
if (returnValue is null)
Copy link
Member

Choose a reason for hiding this comment

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

nit: I wonder if you can use the conventional Try* pattern here for a more readable (my opinion) code.

Suggested change
double? returnValue = TryConvertDecimalOrHexToDouble(number);
if (returnValue is null)
if (!TryConvertDecimalOrHexToDouble(number, out double returnValue))

Same for all the TryConvert* methods you're introducing.

Comment on lines 129 to 148
if (_value.Length > 2)
{
if (_value[1] != '(' || _value[_value.Length - 1] != ')' || (_value[0] != '$' && _value[0] != '%' && _value[0] != '@'))
{
// This isn't just a property, item, or metadata value, and it isn't empty.
return false;
}
}
else if (_value.Length == 0)
{
_cachedExpandedValue = String.Empty;
return true;
}
else
{
_cachedExpandedValue = _value;
return false;
}
Copy link
Member

Choose a reason for hiding this comment

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

nit: Maybe use a switch? And/or comment, the last else block is especially hard to understand.

Suggested change
if (_value.Length > 2)
{
if (_value[1] != '(' || _value[_value.Length - 1] != ')' || (_value[0] != '$' && _value[0] != '%' && _value[0] != '@'))
{
// This isn't just a property, item, or metadata value, and it isn't empty.
return false;
}
}
else if (_value.Length == 0)
{
_cachedExpandedValue = String.Empty;
return true;
}
else
{
_cachedExpandedValue = _value;
return false;
}
switch (_value.Length)
{
case 0: _cachedExpandedValue = String.Empty; return true;
case 1:
case 2: _cachedExpandedValue = _value; return false;
default:
{
if (_value[1] != '(' || _value[_value.Length - 1] != ')' || (_value[0] != '$' && _value[0] != '%' && _value[0] != '@'))
{
// This isn't just a property, item, or metadata value, and it isn't empty.
return false;
}
}
}

Copy link
Member Author

Choose a reason for hiding this comment

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

Sounds good! The 1/2 case is for if it's nonempty but not long enough to have @/$/%, (, and ), so it has some characters that stay as they are. I can add a comment to make that clearer.

{
return left != right;
return right is null || left != right.Value;
Copy link
Member

Choose a reason for hiding this comment

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

nit: I'd check this in the caller to avoid the asymmetry. Should be easy if you switch to the bool Try(.., out value) pattern suggested in my other comment.

When calculating a value to see if we can do a type of comparison, store
that value so we don't have to calculate it again. Use Try*Evaluate
instead of Can*Evaluate and *Evaluate.
@Forgind
Copy link
Member Author

Forgind commented Oct 4, 2021

image

I tried putting in the "throw error" commit (which I think should resolve the error problem), and although I only ran a few tests, it was faster than without it. It doesn't make sense to me, since it should be defaulting to a string comparison more often, but ¯\(ツ)/¯ It's hard to argue with both more correct and faster.

@@ -110,12 +110,14 @@ internal static List<ItemExpressionCapture> GetReferencedItemExpressions(string
{
List<ItemExpressionCapture> subExpressions = null;

if (expression.IndexOf('@') < 0)
int startInd = expression.IndexOf('@', start, end - start);
Copy link
Member

Choose a reason for hiding this comment

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

ideally we should avoid abbreviations, so startIndex would be better in my opinion

_cachedExpandedValue = _value;
return false;
default:
if (_value[1] != '(' || _value[_value.Length - 1] != ')' || (_value[0] != '$' && _value[0] != '%' && _value[0] != '@'))
Copy link
Member

Choose a reason for hiding this comment

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

I suspect it's totally irrelevant here, but this set off my nano-optimization sense. I suspect that if this were a critically hot loop, and depending on input characteristics, you might observe a speedup with this reordering:

Suggested change
if (_value[1] != '(' || _value[_value.Length - 1] != ')' || (_value[0] != '$' && _value[0] != '%' && _value[0] != '@'))
if (_value[1] != '(' || (_value[0] != '$' && _value[0] != '%' && _value[0] != '@') || _value[_value.Length - 1] != ')')

For a long _value the CPU might have to fault in the memory for the end of the string when accessing it, but we're guaranteed that the second character of the string was loaded at the same time as the first, so this can avoid cache misses.

Our strings are usually short so this probably won't generally matter, and even if it did it probably wouldn't matter much. But I know this kind of thing is up your alley so I figured I'd mention it :)

Comment on lines +62 to +67
// The left child evaluating to a number and the right child not evaluating to a number
// is insufficient to say they are not equal because $(MSBuildToolsVersion) evaluates to
// the string "Current" most of the time but when doing numeric comparisons, is treated
// as a version and returns "17.0" (or whatever the current tools version is). This means
// that if '$(MSBuildToolsVersion)' is "equal" to BOTH '17.0' and 'Current' (if 'Current'
// is 17.0).
Copy link
Member

Choose a reason for hiding this comment

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

Nice comment 👍🏻


internal override bool TryNumericEvaluate(ConditionEvaluator.IConditionEvaluationState state, out double result)
{
if (ShouldBeTreatedAsVisualStudioVersion(state))
Copy link
Member

Choose a reason for hiding this comment

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

Should NumericEvaluate be reimplemented in terms of TryNumericEvaluate to reduce code duplication?

Copy link
Member

Choose a reason for hiding this comment

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

Or alternately should we entirely remove the non-Try versions?

Removes all *Evaluate and Can*Evaluate except in OperatorExpressionNodes, for which CanBoolEvaluate always returns true, so using a simple BoolEvaluate makes sense.

Note that for the NumericComparisonExpressionNode, we could save a tiny amount of time by only calling TryEvaluate when it's actually used (and using if/else as before), but that would since something can be both a number and a version, that would mean we would have to check both regardless of what we had previously found, which means it would be noticeably messier for (probably) very little perf gain, so I opted against that. Switch statements are so pretty 🙂
Apparently that's the best supported way
I tested once with and once without this change, and with this change, Evaluate was 3% faster...that sounds noisy, but I'll still take it.
Copy link
Member

@ladipro ladipro left a comment

Choose a reason for hiding this comment

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

The change looks great. I've left a few comments related to what you brought up in our 1:1 chat. There are places where we evaluate arguments which are used only in error cases. These calls don't look super expensive but they're avoidable nonetheless (ex: NotExpressionNode.GetExpandedValue() allocates a string). Thank you!

Comment on lines +21 to +27
ProjectErrorUtilities.VerifyThrowInvalidProject
(LeftChild.TryBoolEvaluate(state, out bool boolValue),
state.ElementLocation,
"ExpressionDoesNotEvaluateToBoolean",
LeftChild.GetUnexpandedValue(state),
LeftChild.GetExpandedValue(state),
state.Condition);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
ProjectErrorUtilities.VerifyThrowInvalidProject
(LeftChild.TryBoolEvaluate(state, out bool boolValue),
state.ElementLocation,
"ExpressionDoesNotEvaluateToBoolean",
LeftChild.GetUnexpandedValue(state),
LeftChild.GetExpandedValue(state),
state.Condition);
if (!LeftChild.TryBoolEvaluate(state, out bool boolValue))
{
ProjectErrorUtilities.ThrowInvalidProject
state.ElementLocation,
"ExpressionDoesNotEvaluateToBoolean",
LeftChild.GetUnexpandedValue(state),
LeftChild.GetExpandedValue(state),
state.Condition);
}

to avoid the GetUnexpandedValue() and GetExpandedValue() calls in the happy case, as discussed.

Copy link
Member Author

Choose a reason for hiding this comment

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

Just finished pushing the VerifyThrow --> Throw change in the other PR.

Comment on lines +52 to +53
isLeftNum ? RightChild.GetUnexpandedValue(state) : LeftChild.GetUnexpandedValue(state),
isLeftNum ? RightChild.GetExpandedValue(state) : LeftChild.GetExpandedValue(state));
Copy link
Member

Choose a reason for hiding this comment

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

Also probably worth optimizing by not making these calls in the happy case.

Comment on lines 25 to 26
LeftChild.GetUnexpandedValue(state),
LeftChild.GetExpandedValue(state),
Copy link
Member

Choose a reason for hiding this comment

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

These calls can be avoided in the happy case.

Comment on lines 40 to 41
RightChild.GetUnexpandedValue(state),
RightChild.GetExpandedValue(state),
Copy link
Member

Choose a reason for hiding this comment

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

These calls can be avoided in the happy case.

@rainersigwald rainersigwald added this to the MSBuild 17.1 milestone Oct 25, 2021
@Forgind Forgind added the merge-when-branch-open PRs that are approved, except that there is a problem that means we are not merging stuff right now. label Oct 25, 2021
@rokonec rokonec merged commit a80e227 into dotnet:main Oct 26, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
merge-when-branch-open PRs that are approved, except that there is a problem that means we are not merging stuff right now.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants