Коллеги наткнулись на интересное падение производительности. Подняли тему на stackoverflow.
Наткнулись в реальном коде, им удалось воссоздать минимальный пример.
Есть generic класс
public class BaseClass<T>
{
private List<T> _list = new List<T>();
public BaseClass()
{
// если убрать эту строку из конструктора, то падения производительности в Run не происходит
Enumerable.Empty<T>();
// или Enumerable.Repeat(new T(), 10);
// или даже new T();
// или foreach (var item in _list) {}
// или что-либо, что ссылается на тип T
}
public void Run()
{
for (var i = 0; i < 8000000; i++)
{
_list.Any();
}
}
}
создается наследник
public class DerivedClass : BaseClass<object>
{
}
Далее делают замеры метода Run из обоих классов. Предвосхищая вопросы о методах замера, они пробовали и с разогревом и меняя местами строки Measure.
Компилировали в Release-режиме под .NET 4.5.
public class Program
{
public static void Main()
{
Measure(new DerivedClass());
Measure(new BaseClass<object>());
}
private static void Measure(BaseClass<object> baseClass)
{
var sw = Stopwatch.StartNew();
baseClass.Run();
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);
}
}
Вызов метода Run в наследованном классе DerivedClass в 4 раза медленнее, чем в BaseClass<object>.
Есть мысли, почему так может быть?
Если скомпилировать в 64-битном режиме (убрать галку prefer 32-bit), то
вариант Main без разогрева
public static void Main()
{
Measure(new DerivedClass());
Measure(new BaseClass<object>());
}
Выдает в 64-битном режиме ускорение наследника
528
666
Но если сделать разогрев:
public static void Main()
{
var derivedClass = new DerivedClass();
var baseClass = new BaseClass<object>();
derivedClass.Run();
baseClass.Run();
Measure(derivedClass);
Measure(baseClass);
}
то в 64-битном режиме получаем все то же падение скорости у наследника
Здравствуйте, Димчанский, Вы писали:
Д>Коллеги наткнулись на интересное падение производительности. Подняли тему на stackoverflow. Д>Наткнулись в реальном коде, им удалось воссоздать минимальный пример.
Немного поиграю в кэпа
1. Проблема с энумераторами. Чтобы проверить: заменяем list.Any(); на _list.GetEnumerator().MoveNext() и получаем ещё более забавные результаты.
2. JIT-выхлоп для самих вызовов не отличается, если я ничего не пропустил. Остаётся выравнивание на стеке/попадание в линии кэша процессора. Но чтобы так стабильно воспроизводилось на разных машинах?
Здравствуйте, Димчанский, Вы писали:
Д>Вызов метода Run в наследованном классе DerivedClass в 4 раза медленнее, чем в BaseClass<object>. Д>Есть мысли, почему так может быть?
JIT чудит.
public class BaseClass<T>
{
private static readonly T[] stub = new T[0];
private List<T> _list = new List<T>();
public BaseClass()
{
var x = new Func<IEnumerable<T>>(Enumerable.Empty<T>);
//_list.Add(default(T));
// or Enumerable.Repeat(new T(), 10);
// or even new T();
// or foreach (var item in _list) {}
// or something that relates to T
}
public void Run()
{
for (var i = 0; i < 8000000; i++)
{
_list.Any();
}
}
public void Run2() // не вызывается
{
for (var i = 0; i < 8000000; i++)
{
_list.Any();
}
}
public void Run3() // не вызывается
{
for (var i = 0; i < 8000000; i++)
{
_list.Any();
}
}
}
Комментируем Run2 или Run3 — получаем старые результаты. Не комментируем — всё ок.
Что-то не то с размером methodTable или с выравниванием?
using System;
using System.Runtime.CompilerServices;
public static class Foo
{
public static void Bar<T>() { }
public static void Bar2<T>() { }
[MethodImpl(MethodImplOptions.NoInlining)]
public static void Bar3<T>() { }
}
public class A<T>
{
public void Test()
{
Foo.Bar<T>();
//Foo.Bar3<T>();
Foo.Bar2<T>();
for (int i = 0; i < 1000000; i++)
Foo.Bar3<T>();
}
}
public sealed class B : A<object>
{
}
public class Program
{
public static void Main()
{
var sw = System.Diagnostics.Stopwatch.StartNew();
new A<object>().Test();
sw.Stop();
Console.WriteLine("A<object> " + sw.ElapsedMilliseconds);
sw.Restart();
new B().Test();
sw.Stop();
Console.WriteLine("B " + sw.ElapsedMilliseconds);
Console.ReadLine();
}
}
A<object> 11
B 110
если раскомментировать Foo.Bar3<T>()
A<object> 2
B 2
Ага. Это упрощённый пример топикстартера. И "лечится" точно так же:
public class A<T>
{
public void Test()
{
Foo.Bar<T>();
//Foo.Bar3<T>();
Foo.Bar2<T>();
for (int i = 0; i < 1000000; i++)
Foo.Bar3<T>();
}
public void Test2()
{
}
public void Test3()
{
}
}
в правильном случае генерируется код на проверку вызова clr!JIT_GenericHandleClass (test eax, eax),
в медленном этот метод вызывается всегда, для наследника он работает долго
Здравствуйте, vorona, Вы писали:
V>в правильном случае генерируется код на проверку вызова clr!JIT_GenericHandleClass (test eax, eax), V>в медленном этот метод вызывается всегда, для наследника он работает долго
It is related to dictionary lookups in shared generics code. The heuristic in runtime and JIT do not work well for this particular test. We will take a look what can be done about it.
In the meantime, you can workaround it by adding two dummy methods to the BaseClass (do not even need to be called). It will cause the heuristic to work as one would expect.