Вчера я нашел статью Кристофа Нара под названием «.NET Struct Performance», в которой тестировался тест нескольких языков (C ++, C #, Java, JavaScript) для метода, который добавляет двухточечные структуры ( double
кортежи).
Как выяснилось, версия C ++ занимает около 1000 мс для выполнения (итерация 1e9), в то время как C # не может работать менее ~ 3000 мс на той же машине (и работает еще хуже в x64).
Чтобы проверить это сам, я взял код C # (и немного упростил его, чтобы вызвать только метод, в котором параметры передаются по значению), и запустил его на машине 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
значит, что это дает мне неправильные результаты, потому что я ясно вижу, что это заканчивается через одну секунду.
Кто-нибудь может сказать мне, что здесь может происходить?
(Обновить)
Вот два метода в одной программе, которые показывают, что причина не в JIT:
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, я проверил оптимизированную разборку для обоих методов, и они довольно разные:
Кажется, это показывает, что разница может быть связана с тем, что компилятор в первом случае ведет себя странно, а не с двойным выравниванием полей?
Кроме того, если я добавлю две переменные (общее смещение 8 байт), я все равно получу такой же прирост скорости - и больше не кажется, что это связано с выравниванием полей, упомянутым Гансом Пассантом:
// 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);
}
источник
double
переменными, безstruct
s, поэтому я исключил неэффективность структуры структуры / вызова метода.Ответы:
Обновление 4 объясняет проблему: в первом случае JIT сохраняет вычисленные значения (
a
,b
) в стеке; во втором случае JIT хранит его в регистрах.На самом деле
Test1
работает медленно из-заStopwatch
. Я написал следующий минимальный тест на основе BenchmarkDotNet :Результаты на моем компьютере:
Как мы можем видеть:
WithoutStopwatch
работает быстро (т.к.a = a + b
использует регистры)WithStopwatch
работает медленно (потому чтоa = a + b
использует стек)WithTwoStopwatches
снова работает быстро (потому чтоa = a + b
использует регистры)Поведение JIT-x86 зависит от большого количества различных условий. По какой-то причине первый секундомер заставляет JIT-x86 использовать стек, а второй секундомер позволяет ему снова использовать регистры.
источник
Stopwatch
самом деле работает быстрее . Но если вы поменяете местами порядок, в котором они вызываются вMain
методе, тогда другой метод будет оптимизирован.Есть очень простой способ всегда получить «быструю» версию вашей программы. Project> Properties> Build tab, снимите отметку с опции «Prefer 32-bit», убедитесь, что целевой платформой выбран AnyCPU.
Вы действительно не предпочитаете 32-битную версию, к сожалению, она всегда включена по умолчанию для проектов C #. Исторически сложилось так, что набор инструментов Visual Studio намного лучше работал с 32-битными процессами - старая проблема, которую Microsoft решила. Пора убрать эту опцию, VS2015, в частности, решил последние несколько реальных препятствий на пути к 64-битному коду с совершенно новым джиттером x64 и универсальной поддержкой Edit + Continue.
Хватит болтовни, вы обнаружили важность согласования переменных. Процессор очень заботится об этом. Если переменная неправильно выровнена в памяти, то процессору придется проделать дополнительную работу, чтобы перетасовать байты, чтобы расположить их в правильном порядке. Есть две различные проблемы несовпадения, одна из которых заключается в том, что байты все еще находятся внутри одной строки кэша L1, что требует дополнительного цикла для их перемещения в правильное положение. И еще один очень плохой, тот, который вы нашли, где часть байтов находится в одной строке кэша, а часть - в другой. Это требует двух отдельных обращений к памяти и их склейки. В три раза медленнее.
double
Иlong
типы являются смутьяны в 32-разрядном процессе. Они имеют размер 64 бита. И, таким образом, может быть смещено на 4, CLR может гарантировать только 32-битное выравнивание. Это не проблема в 64-битном процессе, все переменные гарантированно выровнены по 8. Также основная причина, по которой язык C # не может обещать, что они будут атомарными . И почему массивы типа double размещаются в куче больших объектов, когда у них более 1000 элементов. LOH обеспечивает гарантию выравнивания 8. И объясняет, почему добавление локальной переменной решило проблему: ссылка на объект занимает 4 байта, поэтому двойная переменная перемещается на 4, теперь она выравнивается. Случайно.32-разрядный компилятор C или C ++ выполняет дополнительную работу, чтобы исключить смещение двойного значения . Не совсем простая проблема для решения, стек может быть неправильно выровнен при вводе функции, учитывая, что единственной гарантией является то, что он выровнен по 4. В прологе такой функции необходимо проделать дополнительную работу, чтобы выровнять его до 8. Тот же трюк не работает в управляемой программе, сборщик мусора очень заботится о том, где именно находится локальная переменная в памяти. Необходимо, чтобы он мог обнаружить, что объект в куче сборщика мусора все еще ссылается. Он не может должным образом справиться с перемещением такой переменной на 4, потому что стек был смещен при вводе метода.
Это также основная проблема, связанная с дрожанием .NET, которое не поддерживает инструкции SIMD. У них гораздо более строгие требования к выравниванию, которые процессор не может решить сам. SSE2 требует выравнивания 16, AVX требует выравнивания 32. Невозможно получить это в управляемом коде.
И последнее, но не менее важное: также обратите внимание, что это делает выполнение программы C #, работающей в 32-битном режиме, очень непредсказуемой. Когда вы обращаетесь к типу double или long, который хранится как поле в объекте, perf может резко измениться, когда сборщик мусора сжимает кучу. Что перемещает объекты в памяти, такое поле теперь может внезапно смещаться / выравниваться. Очень случайный, конечно, может быть головной болью :)
Что ж, никаких простых исправлений, но будущее за 64-битным кодом. Удалите форсирование джиттера, если Microsoft не изменит шаблон проекта. Может быть, в следующей версии, когда они будут более уверены в Рюджите.
источник
Сузил его кое-что (похоже, влияет только на 32-разрядную среду выполнения CLR 4.0).
Обратите внимание, что расположение
var f = Stopwatch.Frequency;
имеет все значение.Медленно (2700 мс):
Быстро (800 мс):
источник
Stopwatch
также резко меняет скорость. Изменение сигнатуры методаTest1(bool warmup)
и добавление условного выражения вConsole
выводе:if (!warmup) { Console.WriteLine(...); }
также имеет тот же эффект (наткнулся на это при создании своих тестов для воспроизведения проблемы).Кажется, в Jitter есть какая-то ошибка, потому что поведение еще более странное. Рассмотрим следующий код:
Это будет работать в
900
мс, как и внешний секундомер. Однако, если мы удалимif (!warmup)
условие, оно будет выполняться за3000
мс. Что еще более странно, так это то, что следующий код также будет работать в900
мс:Обратите внимание , я удалил
a.X
иa.Y
ссылки отConsole
выхода.Я понятия не имею, что происходит, но для меня это довольно неприятно, и это не связано с наличием внешнего
Stopwatch
или нет, проблема кажется немного более общей.источник
a.X
иa.Y
, компилятор, вероятно, свободен оптимизировать почти все внутри цикла, потому что результаты операции не используются.a.X
иa.Y
не заставляет его работать быстрее, чем когда вы включаетеif (!warmup)
условие или OPouterSw
, что означает, что он ничего не оптимизирует, а просто устраняет любую ошибку, заставляющую код работать с субоптимальной скоростью (3000
мс вместо900
мс).warmup
было правдой, но в этом случае линия даже не печатается, так что случай , когда он действительно получить напечатанной на самом деле ссылкуa
. Тем не менее, я хотел бы убедиться, что всегда ссылаюсь на результаты вычислений где-то ближе к концу метода, когда я тестирую материал.