It potentially could since it resolves to calls on object.
However more pressing is regression in dictionary for object types https://github.com/dotnet/corefx/issues/28511
/cc @AndyAyersMS @danmosemsft @ianhays
category:cq
theme:devirtualization
skill-level:intermediate
cost:medium
I don't believe the jit can devirtualize in this case in general.
For ref type T, EqualityComparer<T>.Default can either be a GenericEqualityComparer<T> or an ObjectEqualityComparer<T>.
In the typical case where the caller of Default is itself generic in T (that is, shared code) both comparer types are always possible at runtime.
If T is exactly known or we can inline EqualityComparer<T>.Default into a context where T is exactly known then it should devirtualize (eg T == string).
As noted in dotnet/corefx#28511 if you are doing a lot of comparisons using the default comparer the performant patterns for value and ref types differ. This is unfortunate. For ref types caching the result of Default in a local is beneficial; for value types it is harmful.
We should investigate what it would take to fix the jit so the same patterns perform equally well for value and ref types.
For ref type
T,EqualityComparer<T>.Defaultcan either be aGenericEqualityComparer<T>or anObjectEqualityComparer<T>.
Hmm... that makes it more complicated and means I can't do a simple fallback to object.Equals as it may be IEquatable<T>.Equals(T) and a different method?
I think best can do is going back to caching in a local for object types 馃槩
Will treat it as a resolution to the regression rather than something more general
As aside (future) could generic lookups in loops be hoisted outside of loops?
Yes, that is part of getting the jit changes correct.
I'd like to make it so that if you call Default in a loop, and don't cache and the jit doesn't devirtualize / inline, it can can hoist the runtime handle lookup and the call to Default out of the loop and effectively re-introduce caching. That fixes things so if you use the new value-optimal pattern you come out mostly ok with refs too.
The second part of the fix is that if you cache the jit needs to be more flexible in propagating the devirtualized comparer to the use site. This is a bit trickier to arrange since devirtualization happens so early, but I think we can make it work provided the caching is done "near" the use. That way if you cache to get good perf for ref types (or have legacy code that cached because prior to all this devirtualization, caching was a win for both ref and value types) you now get the benefits of devirtualization.
Looked into this a bit, here are some notes:
Default when we won't devirtualize EqualsWe likely want to make Default be noinline, as once it gets inlined and the generic static base address lookup expands, it becomes too complex to analyze/hoist. There's probably no real benefit to inlining this anyways. If we do that then we need to adjust various optimizer methods to recognize calls to Default as an intrinsic with special properties: while it has global side effects they are idempotent and so it can be safely hoisted. We can also streamline/specialize the "special DCE" property which indicates that if the result of this call is unused the call can be removed.
I prototyped this a bit and another challenge I hit here was that morph also does some crude side effect analysis for calls and since this call ends up nested, morph creates an embedded assignment. Wasn't clear to me why we did not spill at the outer call in the importer but if/when we do this we will decouple the call to Default and the subsequent use at the virtual site and so introduce instances of the case below.
Also we might want to allow value numbering/CSE of these calls.
Default away from the call to EqualsAssumption here is that in the importer we need to track the actual expression for single def locals and not just its type. Seems like this is not too hard to do though we must be careful about reimportation and such. Then during devirt of Equals if we have a single-def temp or local for this we can look back and see the definition is a call to Default and proceed as we do now. Later the call to Default would be dead coded as its result would no longer be used.
```C#
using System;
using System.Collections.Generic;
class GitHub_17273
{
// Would like to see just one call to Default per call to Hoist
public static int Hoist()
{
int result = 0;
string s0 = "s";
string s1 = "s";
for (int i = 0; i < 100; i++)
{
if (EqualityComparer<string>.Default.Equals(s0, s1))
{
result++;
}
}
return result;
}
// Would like to see call to Default optimized away
// and Equals devirtualized and inlined
public static int Sink(int v0, int v1)
{
int result = 0;
var c = EqualityComparer<int>.Default;
for (int i = 0; i < 100; i++)
{
if (c.Equals(v0, v1))
{
result++;
}
}
return result;
}
// Would like to see just one call to Default per call to Common
public static int Common()
{
int result = 0;
string s0 = "t";
string s1 = "t";
for (int i = 0; i < 50; i++)
{
if (EqualityComparer<string>.Default.Equals(s0, s1))
{
result++;
}
}
for (int i = 0; i < 50; i++)
{
if (EqualityComparer<string>.Default.Equals(s0, s1))
{
result++;
}
}
return result;
}
// Optimization pattern should vary here.
//
// If T is a ref type, Default will be hoisted and Equals will not devirtualize.
// If T is a value type, Default will be removed and Equals will devirtualize.
public static int GeneralHoist<T>(T v0, T v1)
{
int result = 0;
for (int i = 0; i < 100; i++)
{
if (EqualityComparer<T>.Default.Equals(v0, v1))
{
result++;
}
}
return result;
}
// Optimization pattern should vary here.
//
// If T is a ref type we will compile as is.
// If T is a value type, Default will be removed and Equals will devirtualize.
public static int GeneralSink<T>(T v0, T v1)
{
int result = 0;
var c = EqualityComparer<T>.Default;
for (int i = 0; i < 100; i++)
{
if (c.Equals(v0, v1))
{
result++;
}
}
return result;
}
public static int Main()
{
int h = Hoist();
int s = Sink(33, 33);
int c = Common();
int ghr = GeneralHoist<string>("u", "u");
int ghv = GeneralHoist<int>(44, 44);
int gsr = GeneralSink<string>("v", "v");
int gsv = GeneralSink<int>(55, 55);
return h + s + c + ghr + ghv + gsr + gsv - 600;
}
}
```
Most helpful comment
I don't believe the jit can devirtualize in this case in general.
For ref type
T,EqualityComparer<T>.Defaultcan either be aGenericEqualityComparer<T>or anObjectEqualityComparer<T>.In the typical case where the caller of
Defaultis itself generic inT(that is, shared code) both comparer types are always possible at runtime.If
Tis exactly known or we can inlineEqualityComparer<T>.Defaultinto a context whereTis exactly known then it should devirtualize (egT == string).As noted in dotnet/corefx#28511 if you are doing a lot of comparisons using the default comparer the performant patterns for value and ref types differ. This is unfortunate. For ref types caching the result of
Defaultin a local is beneficial; for value types it is harmful.We should investigate what it would take to fix the jit so the same patterns perform equally well for value and ref types.