Эта статья входит в серию статей по улучшению производительности в .NET. Первую статью можно найти здесь.
В этой статье, как и в прошлой, будут рассмотрены неочевидные особенности в работе платформы, и приемы, с помощью которых можно с этими особенностями мирно сосуществовать. Проще говоря, тут будут записаны очередные всякие хаки из жизни нашей лавки, которые хоть немного (или много, как всегда все зависит от контекста), но экономят драгоценные ресурсы в условиях высокой нагрузки. Хаки предельно просты и не влияют на конечную функциональность.
Открывая очередную статью о производительности, пожалуй каждый резонно ищет ответ на вопросы «а как это может помочь в моих проектах» и «бросать ли все и начинать ли срочно исправлять свой код как описано». Исправлю ошибку прошлой статьи, отвечу сразу и подробнее. На мой взгляд, есть смысл использовать наши хаки в следующих случаях:
- Вы пишете новый код и решили делать это сразу круто и с экономией
- Вы дошли до этапа, когда начались оптимизации и рефакторинг, поэтому просматриваете и меняете участки кода, которые выполняются очень часто
- Вы пишете высокопроизводительную систему и экономите на спичках
Я ни в коем случае не призываю вас бежать и исправлять весь код в ваших проектах. Более того, я призываю вас не делать этого, потому что это простые хаки, и скорее всего они будут вноситься бездумно в большое количество кода, а это может повлечь за собой появление новых ошибок. Это не те хаки, которые скинут бабу с воза вашего приложения и заставят кобылу-сервак бежать в десять раз быстрее. В лучшем случае удастся выжать процентов десять. Но с миру по нитке — голому рубашка.
1. Сравнение строк
Многие, наверное, знают, что такое StringComparison.Ordinal. Эта опция предназначена для сравнения строк без использования культур. Сравнение с помощью нее отрабатывает гораздо быстрей, чем с использованием культур, потому что для сравнения используются коды символов без дополнительных обработок. Мы уже давно используем такое сравнение во всех местах, где это возможно. Вполне ожидаемо, что на этом месте вы скажете: «Ну и что тут такого? Мы тоже делаем так же!». Но это еще не всё.
Давайте установим небольшое расширение для Visual Studio: Concurrency Visualizer. С его помощью можно видеть работу потоков и моменты их синхронизации.
Напишем небольшой тест, который будет сравнивать строки используя культуру:
const Int32 length = 10000;
var tasks = new Task[length];
var strings = Enumerable.Range(0, length).Select(i => Guid.NewGuid().ToString()).ToArray();
for (var i = 0; i < length; ++i)
{
tasks[i] = Task.Run(() =>
{
for (var j = 0; j < length; ++j)
{
if (j != (length - j - 1) && String.Compare(strings[j], strings[length - j - 1], StringComparison.CurrentCulture) == 0)
break;
}
});
}
Task.WaitAll(tasks);
Теперь посмотрим, что будет, если запустить проект под Concurrency Visualizer, и оставить для рассмотрения только потоки, которые занимались делом:
Если нажать Demistify и ткнуть на красные блоки, то увидим, что это — остановка потока, вызванная обращением к одному из примитивов синхронизации ядра.
Теперь сделаем небольшое изменение: поменяем StringComparison.CurrentCulture на StringComparison.Ordinal. Запускаем заново:
Мы видим, что синхронизации нет, а есть только работа и переключение контекста. Картина для обоих случаев, конечно, меняется от запуска к запуску, но интересное не в том, что Ordinal сравнение работает быстрей (на моей машине этот тест с Ordinal выполнялся в среднем в 10 раз быстрей, чем с CurrentCulture), а в том, что сравнение с помощью культур может вызвать синхронизацию потоков.
А вот небольшая история о том, как мы наткнулись на эту самую синхронизацию потоков в своём коде и начали исследовать эту проблему.
Хоть у нас и сравниваются все строки в коде с помощью Ordinal, но мы забыли, что строки еще и сравниваются внутри различных реализаций IDictionary с ключом строкой. Под большой нагрузкой это становится ощутимо, поэтому мы получили такой stack trace:
Wait Time
2 of 1757: 2.8% (1.328s of 46.718s)
clr.dll!func@0x180008eb0+0x3a - [Unknown]:[Unknown]
clr.dll!func@0x180008e80+0x1e - [Unknown]:[Unknown]
clr.dll!func@0x180008d98+0x77 - [Unknown]:[Unknown]
clr.dll!func@0x180136390+0x2d - [Unknown]:[Unknown]
clr.dll!func@0x180131540+0x132 - [Unknown]:[Unknown]
clr.dll!func@0x18013520c+0x4c - [Unknown]:[Unknown]
mscorlib.ni.dll!func@0x644785619f0+0x170 - [Unknown]:[Unknown]
mscorlib.dll!System::Globalization::CompareInfo::Compare+0x9e - [Unknown]:[Unknown]
Не смотрите на то, что лишь 2.8% потрачено на синхронизацию из-за сравнения строк с культурами. Если вы посмотрите на Concurrency Visualizer, то увидите много потоков, часть из которых просто спит всё время жизни. Их создает ThreadPool и они не используются, но учитываются в общем времени синхронизации. В нашем случае понять, откуда там сваливается в лок сравнивающий строки поток, сложно без исходников нативной части, а исходники управляемой части заканчиваются этим. Можно, конечно, попытаться изучить открытый код mono начиная отсюда, переходя сюда, а затем сюда, но в целом все мы понимаем, что mono все-таки не .net, и это совсем другой код. Наши предположения таковы: либо там стоит где-то лок на доступ к культурам, либо в процессе сравнения выделяются объекты на куче, что в свою очередь тоже может вызвать лок. К сожалению, это лишь предположения, и если кто-то сможет сказать точно, что же там происходит, то с нас печеньки.
После того, как обнаружилась эта проблема, мы стали в конструкторы всех реализаций IDictionary с ключом строкой передавать удобный StringComparer.Ordinal, что еще немного улучшило производительность.
2. Еще немного о boxing: GetEnumerator() в foreach
foreach использует duck typing. То есть на самом деле, чтобы использовать объект в качестве цели для foreach, не нужно иметь реализацию IEnumerable
или IEnumerable<T>
. Достаточно реализовать GetEnumerator(), MoveNext(), Reset() и Current.
Рассмотрим два простых и похожих метода:
private static void IterateClass<T>(List<T> list)
{
foreach (var item in list)
Console.WriteLine(item);
}
private static void IterateInterface<T>(IEnumerable<T> enumerable)
{
foreach (var item in enumerable)
Console.WriteLine(item);
}
Вроде они должны делать одно и то же, но IL код подсказывает нам интересную деталь:
.method private hidebysig static void IterateClass<T>(class [mscorlib]System.Collections.Generic.List`1<!!T> list) cil managed
{
.maxstack 1
.locals init ([0] !!T item,
[1] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!!T> CS$5$0000)
IL_0000: ldarg.0
IL_0001: callvirt instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!0> class [mscorlib]System.Collections.Generic.List`1<!!T>::GetEnumerator()
...
.method private hidebysig static void IterateInterface<T>(class [mscorlib]System.Collections.Generic.IList`1<!!T> list) cil managed
{
.maxstack 1
.locals init ([0] !!T item,
[1] class [mscorlib]System.Collections.Generic.IEnumerator`1<!!T> CS$5$0000)
IL_0000: ldarg.0
IL_0001: callvirt instance class [mscorlib]System.Collections.Generic.IEnumerator`1<!0> class [mscorlib]System.Collections.Generic.IEnumerable`1<!!T>::GetEnumerator()
...
Посмотрев внимательно, увидите, что для второго метода, в котором передается интерфейс, на стеке выделяется объект reference типа IEnumerator`1<!!T>
, а для первого метода создается value тип List`1/Enumerator<!!T>
.
Если делать enumerator, возвращаемый из GetEnumerator(), классом, то при каждом вызове foreach будет создаваться объект на куче. По этой причине все стандартные коллекции делают explicit реализацию интерфейсов IEnumerable
и IEnumerable<T>
, а публичный метод GetEnumerator() возвращает структуру. Например, public List<T>.Enumerator GetEnumerator()
. Но если ваш метод принимает в качестве параметра IEnumerable<T>
для итерации по нему (что и посоветует сделать Resharper, если это будет возможно), то при вызове GetEnumerator() будет происходить boxing из-за того, что структура будет приводиться к интерфейсу IEnumerator. Либо же произойдет создание нового объекта на куче, если в коллекции не предусмотрен value тип итератор.
По этой причине есть три совета:
- Если вы делаете свою коллекцию, то не забывайте делать свою структуру enumerator'а, которая будет возвращаться для foreach. Если вы используете какую-то стандартную коллекцию внутри, то можно просто указывать тип итератора внутренней коллекции в качестве возвращаемого типа для метода GetEnumerator()
- Если есть возможность без вреда для внешнего кода иметь в качестве параметра конкретную коллекцию, а не интерфейс, то используйте ее, это сможет улучшить производительность foreach
- Если возможности иметь в качестве параметра конкретную коллекцию нет, можно, например, использовать IList и for вместо foreach
3. И еще немного о boxing: Перечисления в generic методах
В прошлой статье я много плохого написал о перечислениях. Мы стараемся использовать их пореже. Но если от них не избавиться, то есть в generic методах возможность получать значения перечисления без боксинга. Здесь, например, описан способ генерации метода, который позволяет получать числовые значения перечисления без боксинга. У нас в лавке мы дописали этот метод, и теперь конвертируем спокойно value типы и generic параметры между собой.
Есть лишь один нюанс: данный подход не работает на mono. Но в mono можно дописать необходимую функциональность, и это не очень большие изменения. У себя мы так и сделали. Возможно данные изменения попадут в скором времени и в основную ветку mono.
4. И ещё чуть-чуть о выделении памяти: постоянное создание делегатов
В заключение небольшая мелочь, о которой многие забывают. Предположим есть два метода:
private static void Foo()
{
Console.WriteLine("Foo");
}
private static void Execute(Action action)
{
action();
}
Если сделать вызов Execute(Foo)
, то мы получим следующий IL код:
IL_009c: ldftn void HabraTests.Program::Foo()
IL_00a2: newobj instance void [mscorlib]System.Action::.ctor(object, native int)
IL_00a7: call void HabraTests.Program::Execute(class [mscorlib]System.Action)
То есть при каждом вызове создается объект. Если писать eventName += methodName;
или просто подставлять название метода в вызове принимающего делегат метода, то этим самым создается дополнительный объект. Когда это происходит редко, то в этом нет ничего страшного, но если такой код выполняется часто, то плодится большое количество объектов. Если у вас есть такие часто использующиеся участки кода, то лучше сохранять делегат и передавать сохраненный объект, например, так:
private static Action _fooAction;
public static void Main()
{
_fooAction = Foo;
Execute(_fooAction);
}
IL_0011: ldsfld class [mscorlib]System.Action HabraTests.Program::_fooAction
IL_0016: call void HabraTests.Program::Execute(class [mscorlib]System.Action)
Вот и всё.
Напоследок хотелось бы сказать пару слов о методике оптимизации, которой я стараюсь следовать. Большинство проблем кроется в алгоритмах и структурах данных. По этой причине я предпочитаю сначала решать проблемы производительности (да и вообще фиксить баги) аналитическим путем. Таким образом решаются фундаментальные проблемы, а не делается куча подпорок в коде для частных случаев. Только если решить аналитически не получается, то берусь за профайлер и дебаггер.
Еще я придерживаюсь мнения, что если вы что-то решили прооптимизировать, то не стоит сразу пытаться оптимизировать в нём самом каждую мелочь. Лучше быстро набросать грубую модель и посмотреть, есть ли выигрыш в сравнении с предыдущим подходом, а потом уже вылизывать реализацию.
В комментариях к прошлой статье посоветовали замечательное расширение Heap Allocations Viewer. Оно, конечно, хорошо помогает увидеть все выделения памяти в вами написанном коде, но выделения памяти внутри сторонних библиотек там увидеть нельзя. Например, оно не покажет вам, что если использовать перечисление в качестве ключа для словаря, то будет происходить boxing. Отсюда еще одно правило оптимизации: всякие инструменты хорошо, но программист должен осознавать, что происходит в его коде с учетом внутреннего устройства всех сторонних библиотек, если хочется выжать максимум.
P.S. Xочется поблагодарить парней, с которыми облетаем лавочные земли в поисках пропавших байтов и тактов, а также находим всякие такие забавные штуки из статьи. Спасибо вам!
Автор: alekspak