Странное увеличение производительности в простых тестах
Вчера я нашел статью Кристофа Нахра озаглавленную "Производительность .NET Struct" , в которой сравнивались несколько языков (С++, С#, Java, JavaScript) для метода, который добавляет две точечные структуры (double
кортежи).
Как оказалось, версия С++ занимает около 1000 мс для выполнения (1е9 итераций), в то время как С# не может получить до ~ 3000 мс на одной машине (и еще хуже в x64).
Чтобы проверить его сам, я взял код С# (и немного упростил вызов только метода, когда параметры переданы по значению), и запустил его на машине i7-3610QM (повышение 3,1 ГГц для одного ядра), 8 ГБ ОЗУ, Win8.1, используя .NET 4.5.2, RELEASE build 32-bit (x86 WoW64, так как моя ОС 64-разрядная). Это упрощенная версия:
public static class CSharpTest
{
private const int ITERATIONS = 1000000000;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Point AddByVal(Point a, Point b)
{
return new Point(a.X + b.Y, a.Y + b.X);
}
public static void Main()
{
Point a = new Point(1, 1), b = new Point(1, 1);
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
}
С Point
определяется как просто:
public struct Point
{
private readonly double _x, _y;
public Point(double x, double y) { _x = x; _y = y; }
public double X { get { return _x; } }
public double Y { get { return _y; } }
}
Выполнение этого результата приводит к результатам, аналогичным результатам в статье:
Result: x=1000000001 y=1000000001, Time elapsed: 3159 ms
Первое странное наблюдение
Поскольку метод должен быть встроен, я задавался вопросом, как будет выполняться код, если бы я полностью удалил структуры и просто вложил все это вместе:
public static class CSharpTest
{
private const int ITERATIONS = 1000000000;
public static void Main()
{
// not using structs at all here
double ax = 1, ay = 1, bx = 1, by = 1;
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
{
ax = ax + by;
ay = ay + bx;
}
sw.Stop();
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
ax, ay, sw.ElapsedMilliseconds);
}
}
И получил практически тот же результат (на самом деле 1% медленнее после нескольких попыток), что означает, что JIT-ter, похоже, делает хорошую работу, оптимизируя все вызовы функций:
Result: x=1000000001 y=1000000001, Time elapsed: 3200 ms
Это также означает, что эталонный показатель, по-видимому, не измеряет производительность struct
и фактически только измеряет базовую арифметику double
(после того, как все остальное будет оптимизировано).
Странный материал
Теперь приходит странная часть. Если я просто добавлю еще один секундомер вне цикла (да, я сузил его до этого сумасшедшего шага после нескольких попыток), код работает в три раза быстрее:
public static void Main()
{
var outerSw = Stopwatch.StartNew(); // <-- added
{
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
outerSw.Stop(); // <-- added
}
Result: x=1000000001 y=1000000001, Time elapsed: 961 ms
Это смешно! И это не нравится Stopwatch
дает мне неправильные результаты, потому что я могу ясно видеть, что он заканчивается через одну секунду.
Может ли кто-нибудь сказать мне, что может происходить здесь?
(Обновление)
Вот два метода в одной программе, которые показывают, что причина не в JITting:
public static class CSharpTest
{
private const int ITERATIONS = 1000000000;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Point AddByVal(Point a, Point b)
{
return new Point(a.X + b.Y, a.Y + b.X);
}
public static void Main()
{
Test1();
Test2();
Console.WriteLine();
Test1();
Test2();
}
private static void Test1()
{
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
private static void Test2()
{
var swOuter = Stopwatch.StartNew();
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
swOuter.Stop();
}
}
Вывод:
Test1: x=1000000001 y=1000000001, Time elapsed: 3242 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 974 ms
Test1: x=1000000001 y=1000000001, Time elapsed: 3251 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 972 ms
Вот пастебин. Вам нужно запустить его как 32-разрядную версию на .NET 4. x (для этого есть несколько проверок кода).
(Обновление 4)
Следуя комментариям @usr на ответ @Hans, я проверил оптимизированную разборку для обоих методов, и они довольно разные:
![Test1 слева, Test2 справа]()
Кажется, это показывает, что разница может быть связана с компилятором, играющим смешно в первом случае, а не с двойным выравниванием поля?
Кроме того, если я добавлю переменные два (суммарное смещение 8 байтов), я все равно получаю такое же ускорение скорости - и это уже не похоже на упоминание выравнивания полей Hans Passant:
// this is still fast?
private static void Test3()
{
var magical_speed_booster_1 = "whatever";
var magical_speed_booster_2 = "whatever";
{
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
GC.KeepAlive(magical_speed_booster_1);
GC.KeepAlive(magical_speed_booster_2);
}
Ответы
Ответ 1
Обновление 4 объясняет проблему: в первом случае JIT хранит вычисленные значения (a
, b
) в стеке; во втором случае JIT хранит его в регистрах.
Фактически, Test1
работает медленно из-за Stopwatch
. Я написал следующий минимальный критерий, основанный на BenchmarkDotNet:
[BenchmarkTask(platform: BenchmarkPlatform.X86)]
public class Jit_RegistersVsStack
{
private const int IterationCount = 100001;
[Benchmark]
[OperationsPerInvoke(IterationCount)]
public string WithoutStopwatch()
{
double a = 1, b = 1;
for (int i = 0; i < IterationCount; i++)
{
// fld1
// faddp st(1),st
a = a + b;
}
return string.Format("{0}", a);
}
[Benchmark]
[OperationsPerInvoke(IterationCount)]
public string WithStopwatch()
{
double a = 1, b = 1;
var sw = new Stopwatch();
for (int i = 0; i < IterationCount; i++)
{
// fld1
// fadd qword ptr [ebp-14h]
// fstp qword ptr [ebp-14h]
a = a + b;
}
return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
}
[Benchmark]
[OperationsPerInvoke(IterationCount)]
public string WithTwoStopwatches()
{
var outerSw = new Stopwatch();
double a = 1, b = 1;
var sw = new Stopwatch();
for (int i = 0; i < IterationCount; i++)
{
// fld1
// faddp st(1),st
a = a + b;
}
return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
}
}
Результаты на моем компьютере:
BenchmarkDotNet=v0.7.7.0
OS=Microsoft Windows NT 6.2.9200.0
Processor=Intel(R) Core(TM) i7-4702MQ CPU @ 2.20GHz, ProcessorCount=8
HostCLR=MS.NET 4.0.30319.42000, Arch=64-bit [RyuJIT]
Type=Jit_RegistersVsStack Mode=Throughput Platform=X86 Jit=HostJit .NET=HostFramework
Method | AvrTime | StdDev | op/s |
------------------- |---------- |---------- |----------- |
WithoutStopwatch | 1.0333 ns | 0.0028 ns | 967,773.78 |
WithStopwatch | 3.4453 ns | 0.0492 ns | 290,247.33 |
WithTwoStopwatches | 1.0435 ns | 0.0341 ns | 958,302.81 |
Как мы можем видеть:
-
WithoutStopwatch
работает быстро (потому что a = a + b
использует регистры)
-
WithStopwatch
работает медленно (потому что a = a + b
использует стек)
-
WithTwoStopwatches
работает быстрее (потому что a = a + b
использует регистры)
Поведение JIT-x86 зависит от большого количества разных условий. По какой-то причине первый секундомер заставляет JIT-x86 использовать стек, а второй секундомер позволяет ему снова использовать регистры.
Ответ 2
Существует очень простой способ всегда получать "быструю" версию вашей программы. Project > Properties > Build tab, отключите опцию "Предпочитайте 32-разрядную", убедитесь, что целевой целевой объект платформы - AnyCPU.
Вы действительно не предпочитаете 32-битный, к сожалению, всегда включается по умолчанию для проектов С#. Исторически, набор инструментов Visual Studio работал намного лучше с 32-битными процессами, старая проблема, с которой Microsoft отжимала. Время, чтобы удалить эту опцию, VS2015, в частности, обратился к последним нескольким реальным дорожным блокам с 64-битным кодом с совершенно новым джиттером x64 и универсальной поддержкой Edit + Continue.
Достаточная болтовня, то, что вы обнаружили, - это важность выравнивания переменных. Процессор обожает это очень много. Если переменная неверно выровнена в памяти, тогда процессор должен выполнить дополнительную работу, чтобы перетасовать байты, чтобы получить их в правильном порядке. Существуют две различные проблемы несогласованности, одна из которых заключается в том, что байты все еще находятся внутри одной строки кеша L1, что требует дополнительного цикла, чтобы перевести их в правильное положение. И лишний плохой, тот, который вы нашли, где часть байтов находится в одной строке кэша и часть в другой. Для этого требуется два отдельных доступа к памяти и их склеивание. Три раза медленнее.
Типы double
и long
являются создателями проблем в 32-битном процессе. Они имеют размер 64 бит. И может получиться таким образом, что он будет смещен на 4, CLR может гарантировать только 32-битное выравнивание. Не проблема в 64-битном процессе, все переменные гарантированно будут выровнены по 8. Также основная причина, по которой язык С# не может обещать, что он является атомарным. И почему массивы double выделяются в кучке больших объектов, когда у них более 1000 элементов. LOH обеспечивает гарантию выравнивания 8. И объясняет, почему добавление локальной переменной решало проблему, ссылка на объект - 4 байта, поэтому она перемещала двойную переменную на 4, теперь ее выравнивание. Случайно.
32-разрядный компилятор C или С++ выполняет дополнительную работу, чтобы гарантировать, что double не может быть смещен. Не совсем простая проблема для решения, стек может быть смещен при вводе функции, учитывая, что единственная гарантия заключается в том, что она выровнена с 4. Пролог такой функции должен выполнить дополнительную работу, чтобы выровнять ее с 8. Тот же трюк не работает в управляемой программе, сборщик мусора много заботится о том, где именно локальная переменная находится в памяти. Необходимо, чтобы он мог обнаружить, что объект в куче GC по-прежнему ссылается. Он не может правильно обрабатывать такую переменную, которая перемещается на 4, потому что при вводе метода стек был смещен.
Это также основная проблема с ошибками .NET, которые нелегко поддерживают SIMD-инструкции. У них гораздо более высокие требования к выравниванию, которые процессор не может решить сам по себе. SSE2 требует выравнивания 16, для AVX требуется выравнивание 32. Не удается получить это в управляемом коде.
И последнее, но не менее важное: обратите внимание, что это делает первичную программу С#, которая работает в 32-битном режиме, очень непредсказуема. Когда вы получаете доступ к двойному или длинному, который хранится как поле в объекте, тогда перфомант может резко измениться, когда сборщик мусора сжимает кучу. Который перемещает объекты в памяти, такое поле теперь может внезапно оказаться ошибочным/выровненным. Конечно, очень случайный, может быть довольно головоломкой:)
Ну, никаких простых исправлений, кроме одного, 64-битного кода - это будущее. Удалите дрожание, пока Microsoft не изменит шаблон проекта. Возможно, следующая версия, когда они будут чувствовать себя более уверенно в Ryujit.
Ответ 3
Сузилось какое-то (что, похоже, влияет на 32-разрядную среду CLR 4.0).
Обратите внимание, что размещение var f = Stopwatch.Frequency;
делает все возможное.
Медленный (2700 мс):
static void Test1()
{
Point a = new Point(1, 1), b = new Point(1, 1);
var f = Stopwatch.Frequency;
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
Быстрый (800 мс):
static void Test1()
{
var f = Stopwatch.Frequency;
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
Ответ 4
Кажется, что есть ошибка в дрожании, потому что поведение еще более странное. Рассмотрим следующий код:
public static void Main()
{
Test1(true);
Test1(false);
Console.ReadLine();
}
public static void Test1(bool warmup)
{
Point a = new Point(1, 1), b = new Point(1, 1);
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
if (!warmup)
{
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
}
Это будет работать в 900
ms, как и в случае с внешним секундомером. Однако, если мы удалим условие if (!warmup)
, оно будет работать в 3000
ms. Что еще более странно, так это то, что следующий код будет также работать в 900
ms:
public static void Test1()
{
Point a = new Point(1, 1), b = new Point(1, 1);
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
0, 0, sw.ElapsedMilliseconds);
}
Примечание. Я удалил ссылки a.X
и a.Y
из вывода Console
.
Я понятия не имею, что происходит, но это плохо пахнет для меня, и это не связано с внешним Stopwatch
или нет, проблема кажется немного более обобщенной.