A developer made a plugin architecture in which some plugins were built in the same library as the microkernel / core in order to provide a default implementation. Cool! The weird thing is that type checking was still being done using a public member called "Type". Here is an example of the implementation:
class BaseType
{
public virtual string Type { get => "BaseType"; }
}
class SomeType : BaseType
{
public override string Type => "SomeType";
}
class SomeOtherType : BaseType
{
public override string Type => "SomeOtherType";
}
Yes, that works, but isn't it slower than using the object oriented features of the language and the optimization of the compiler? I think so, but let's see. To do this, we'll have to test string comparisons against type checking.
For type checking, we'll use the is
operator.
Let's try a few approaches to string equality that will demonstrate the differences:
/// <summary>
/// Runs a test given a number of items
/// </summary>
/// <param name="numberOfItems">The number of items</param>
public static Dictionary<string, long> Run(int numberOfItems)
{
var ret = new Dictionary<string, long>();
IEnumerable<BaseType> collection = Enumerable.Range(1, numberOfItems)
.Select(i => i % 2 == 0 ? new SomeType() as BaseType : new SomeOtherType())
.ToArray();
var current = new BaseType();
foreach (var act in new Action[]
{
() => {
var sw = System.Diagnostics.Stopwatch.StartNew();
foreach (var item in collection)
{
if (item.Type == "SomeType")
{
current = item;
}
}
sw.Stop();
ret.Add("String ==", sw.Elapsed.Ticks);
},
() => {
var sw = System.Diagnostics.Stopwatch.StartNew();
foreach (var item in collection)
{
if (item.Type.Equals("SomeType"))
{
current = item;
}
}
sw.Stop();
ret.Add("String Equals", sw.Elapsed.Ticks);
},
() => {
var sw = System.Diagnostics.Stopwatch.StartNew();
foreach (var item in collection)
{
if (item.Type.Equals("SomeType", StringComparison.InvariantCultureIgnoreCase))
{
current = item;
}
}
sw.Stop();
ret.Add("String Equals Invariant", sw.Elapsed.Ticks);
},
() => {
var sw = System.Diagnostics.Stopwatch.StartNew();
foreach (var item in collection)
{
if (item is SomeType)
{
current = item;
}
}
sw.Stop();
ret.Add("is Type Check", sw.Elapsed.Ticks);
}
}.Shuffle()) { act.RunWithNoGarbageCollection(); };
return ret;
}
Here is the average of 30 runs, run in random orders (all times in Ticks):
Average Time (in ticks) to access N number of items
String == | String Equals | String Equals Invariant | is Type Check | |
---|---|---|---|---|
1 | 11.925 | 13.487 | 45.047 | 10.392 |
2 | 12.864 | 15.167 | 48.44 | 11.325 |
4 | 13.696 | 16.15 | 50.65 | 12.475 |
8 | 16.083 | 17.558 | 51.637 | 13.183 |
16 | 18.067 | 20.408 | 63.56 | 16.217 |
32 | 23.7 | 24.535 | 80.202 | 16.425 |
64 | 32.846 | 35.577 | 112.39 | 19.675 |
128 | 53.361 | 58.681 | 174.96 | 27.106 |
256 | 92.019 | 99.518 | 301.69 | 40.096 |
512 | 177.897 | 180.638 | 553.351 | 68.974 |
1024 | 338.904 | 356.287 | 1066.198 | 119.991 |
2048 | 682.336 | 707.819 | 2114.759 | 232.5 |
4096 | 1262.065 | 1407.795 | 4029.673 | 455.214 |
8192 | 2497.223 | 2624.011 | 7939.168 | 899.913 |
16384 | 5013.053 | 5628.336 | 15733.758 | 1752.364 |
32768 | 10004.34 | 10333.581 | 30981.649 | 3359.26 |
65536 | 20146.158 | 20703.968 | 64612.33 | 6623.143 |
131072 | 40068 | 41892.642 | 124974.116 | 13459.447 |
262144 | 80618 | 86399.627 | 253479.968 | 27839.99 |
524288 | 161915.789 | 168553.032 | 513144.447 | 58021.25 |
1048576 | 325638.787 | 338887.958 | 1021087.558 | 108224.547 |
2097152 | 649893.726 | 676123.916 | 2042077.179 | 216568.176 |
Let's normalize to a single approach: Type checking.
Normalized Average Time (with respect to is Type Check)
items | String == | String Equals | String Equals Invariant | is Type Check |
---|---|---|---|---|
4 | 1.14751732101617 | 1.29782525019246 | 4.33477675134719 | 1 |
8 | 1.1358940397351 | 1.33924944812362 | 4.27726269315673 | 1 |
16 | 1.09787575150301 | 1.29458917835671 | 4.06012024048096 | 1 |
32 | 1.21998027763028 | 1.33186679814913 | 3.91693848137753 | 1 |
64 | 1.11407781957205 | 1.25843250909539 | 3.91934389837825 | 1 |
128 | 1.44292237442922 | 1.4937595129376 | 4.88292237442922 | 1 |
256 | 1.66942820838628 | 1.80823379923761 | 5.71232528589581 | 1 |
512 | 1.96860473695861 | 2.16487124621855 | 6.45465948498487 | 1 |
1024 | 2.29496707901038 | 2.48199321628093 | 7.52419193934557 | 1 |
2048 | 2.57918925972105 | 2.61892887174877 | 8.02260271986546 | 1 |
4096 | 2.82441183088732 | 2.96928102941054 | 8.88564975706511 | 1 |
8192 | 2.93477849462366 | 3.04438279569892 | 9.0957376344086 | 1 |
16384 | 2.77246525809839 | 3.09260040332679 | 8.85226069496984 | 1 |
32768 | 2.77496046840083 | 2.91584964324329 | 8.82215058566772 | 1 |
65536 | 2.86073726691486 | 3.21185324510204 | 8.97859006462128 | 1 |
131072 | 2.97813804230694 | 3.07614802069503 | 9.22276007215875 | 1 |
262144 | 3.04178212670329 | 3.12600346995377 | 9.75553902429708 | 1 |
524288 | 2.9769425148002 | 3.11250841137827 | 9.28523408131107 | 1 |
1048576 | 2.89576253439746 | 3.1034359926135 | 9.10488717847959 | 1 |
2097152 | 2.79062910571558 | 2.90502241851046 | 8.8440777646121 | 1 |
4194304 | 3.00891799528623 | 3.1313409701775 | 9.43489796265906 | 1 |
8388608 | 3.00087361866131 | 3.12199109069469 | 9.42925787489663 | 1 |
Visually, this might make more sense on a log2 - log2 plot...
These results are really interesting.
First we see that type checking is the fastest (yay, I was right!).
There has to be some compiler optimizer helping with that.
Next we see that ==
is the fastest comparison.
While Equals
is a little slower, Equals Invariant
is more than 2x slower than that.
Why are some comparisons faster than others? Let's look at the source code...
First we look at the equals operator (==
).
public static bool operator ==(string? a, string? b)
{
if ((object)a == b)
{
return true;
}
if ((object)a == null || (object)b == null || a!.Length != b!.Length)
{
return false;
}
return EqualsHelper(a, b);
}
Not too bad. But it's good to know that EqualsHelper goes through every character and compares its numeric value.
Now check the Equals
method by itself.
That's fairly straight-forward.
public bool Equals(string? value)
{
if ((object)this == value)
{
return true;
}
if ((object)value == null)
{
return false;
}
if (Length != value!.Length)
{
return false;
}
return EqualsHelper(this, value);
}
Now check the Equals
operator with Comparison type (for Equals Invariant
).
public bool Equals(string? value, StringComparison comparisonType)
{
if ((object)this == value)
{
CheckStringComparison(comparisonType);
return true;
}
if ((object)value == null)
{
CheckStringComparison(comparisonType);
return false;
}
switch (comparisonType)
{
case StringComparison.CurrentCulture:
case StringComparison.CurrentCultureIgnoreCase:
return CultureInfo.CurrentCulture.CompareInfo.Compare(this, value, GetCaseCompareOfComparisonCulture(comparisonType)) == 0;
case StringComparison.InvariantCulture:
case StringComparison.InvariantCultureIgnoreCase:
return CompareInfo.Invariant.Compare(this, value, GetCaseCompareOfComparisonCulture(comparisonType)) == 0;
case StringComparison.Ordinal:
if (Length != value!.Length)
{
return false;
}
return EqualsHelper(this, value);
case StringComparison.OrdinalIgnoreCase:
if (Length != value!.Length)
{
return false;
}
return EqualsOrdinalIgnoreCase(this, value);
default:
throw new ArgumentException(SR.NotSupported_StringComparison, "comparisonType");
}
}
Does this difference explain the 16x difference in speed?
Somewhat.
==
and Equals
are nearly the same but ==
is faster.
I guess this is one of those many cases where static methods are faster than instance methods.