Пользователь
0,0
рейтинг
18 августа 2014 в 09:11

Разработка → Улучшаем производительность: полезные советы и приёмы в .NET

Эта статья входит в серию статей по улучшению производительности в .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, но мы забыли, что строки еще и сравниваются внутри SortedList с ключом строкой. Под большой нагрузкой это становится ощутимо, поэтому мы получили такой 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, и это совсем другой код. Наши предположения таковы: либо там стоит где-то лок на доступ к культурам, либо в процессе сравнения выделяются объекты на куче, что в свою очередь тоже может вызвать лок. К сожалению, это лишь предположения, и если кто-то сможет сказать точно, что же там происходит, то с нас печеньки.

После того, как обнаружилась эта проблема, мы стали в конструкторы всех SortedList с ключом строкой передавать удобный StringComparer.Ordinal, что еще немного улучшило производительность.

2. Еще немного о boxing: GetEnumerator() в foreach


foreach использует duck typing. То есть на самом деле, чтобы использовать объект в качестве цели для foreach, не нужно иметь реализацию IEnumerable или
IEnumerable. Достаточно реализовать 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.IEnumerable`1<!!T> enumerable) 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, а публичный метод GetEnumerator() возвращает структуру. Например, public List.Enumerator GetEnumerator(). Но если ваш метод принимает в качестве параметра IEnumerable для итерации по нему (что и посоветует сделать Resharper, если это будет возможно) , то при вызове GetEnumerator() будет происходить boxing из-за того, что структура будет приводиться к интерфейсу IEnumerator. Либо же произойдет создание нового объекта на куче, если в коллекции не предусмотрен value тип итератор.

По этой причине есть три совета:
  • Если вы делаете свою коллекцию, то не забывайте делать свою структуру enumerator'а, которая будет возвращаться для foreach. Если вы используете какую-то стандартную коллекцию внутри, то можно просто указывать тип итератора внутренней коллекции в качестве возвращаемого типа для метода GetEnumerator()
  • Если есть возможность без вреда для внешнего кода иметь в качестве параметра конкретную коллекцию, а не интерфейс, то используйте ее, это сможет улучшить производительность foreach
  • Если возможности иметь в качестве параметра конкретную коллекцию нет, можно, например, использовать IList и for вместо foreach

3. И еще немного о boxing: Перечисления в generic методах


В прошлой статье я много плохого написал о перечислениях. Мы стараемся использовать их пореже. Но если от них не избавиться, то есть в generic методах возможность получать значения перечисления без боксинга. Здесь, например, описан способ генерации метода, который позволяет получать числовые значения перечисления без боксинга. У нас в лавке мы дописали этот метод, и теперь конвертируем спокойно value типы и generic параметры между собой.

Есть лишь один нюанс: данный подход не работает на mono. Но в mono можно дописать необходимую функциональность, и это не очень большие изменения. Проблема кроется в файле Delegate.cs в методе private static bool return_type_match (Type delReturnType, Type returnType)
. После проверки if (!returnType.IsValueType && delReturnType.IsAssignableFrom (returnType)) необходимо дописать еще одну, которая проверяет Type.GetEnumUnderlyingType() в случае, если один из типов Type.IsEnum. У себя мы так и сделали. Возможно данные изменения попадут в скором времени и в основную ветку 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
карма
21,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (19)

  • +3
    Первый пункт интересный, никогда об этом не задумывался. Получается, что та же самая проблема будет при использовании ConcurrentDictionary с ключём-строкой?

    А вот по поводу последнего пункта, мне кажется там ошибка. В таком случае делегат всё равно будет создаваться каждый раз. Правильнее будет

    if(_fooAction == null)
        _fooAction = Foo;
    
    Execute(_fooAction);

    Кстати говоря, если написать Execute(() => Foo()); то компилятор сам сгенерирует статическое поле. Странно, почему он не делает этого всегда.
    • 0
      Не ошибка, просто показал пример того, как сохранять в поле. Ну то есть, его можно использовать потом в любых других вызовах.
    • 0
      Получается, что та же самая проблема будет при использовании ConcurrentDictionary с ключём-строкой?

      Я не знаю, где автор нашёл такие интересные словари, что Dictionary, что ConcurrentDictionary используют Comparer, вызывающий обычный Equals. Видимо, какая-то древность времён .NET 1.0 из серии NameValueCollection.
      • 0
        SortedList, например. Но да, вы правы, при сравнении на равенство такой проблемы нет. Поправлю в статье, чтобы не вводить в заблуждение людей.
        • 0
          Допишите ещё про Contains, IndexOf и StartsWith. Они, если мне не изменяет память, по-умолчанию используют культуру.
          • +1
            Отсюда: referencesource.microsoft.com/#mscorlib/system/string.cs

            Contains
            public bool Contains( string value ) {
                    return ( IndexOf(value, StringComparison.Ordinal) >=0 );
            }
            



            StartsWith (EndsWith такой же)
            public Boolean StartsWith(String value) {
                    if ((Object)value == null) {
                            throw new ArgumentNullException("value");
                    }
                    Contract.EndContractBlock();
                    return StartsWith(value, (LegacyMode ? StringComparison.Ordinal : StringComparison.CurrentCulture));
            }
            



            IndexOf
            public int IndexOf(String value) {
                    return IndexOf(value, (LegacyMode ? StringComparison.Ordinal : StringComparison.CurrentCulture));
            }
            

            • 0
              Стало быть только IndexOf
              • +1
                Почему? Если не LegacyMode, то с ним тоже всё в порядке же:
                public int IndexOf(String value) {
                        return IndexOf(value, (LegacyMode ? StringComparison.Ordinal : StringComparison.CurrentCulture));
                }
                
                • +1
                  Экхм. Вообще-то наоборот. Если НЕ LegacyMode (а это всё кроме некоторых версий сервелата), то используется StringComparison.CurrentCulture.
                  • 0
                    Да, туплю с утра. Хотелось надеяться на лучшее.
                • 0
                  Забавно, а в Mono оно таки использует CurrentCulture как минимум для StartsWith и IndexOf, но не для Contains без всяких LegacyMode.
                  UPDATED: а так это вы нас решили запутать!
    • 0
      Execute(() => Foo()) то закэширует в статическое поле, но вот такой код:

      Execute(() => Foo());
      Execute(() => Foo());

      почему-то создаст два статических поля, а не воспользуется на второй строке первым закэшированным. Туповат компилятор (или я).
      • +2
        Вероятно компилятор даже не пытается «доказать», что 2 функции эквивалентны, т.к. насколько я мне известно из [1, c. 60-62] эта задача неразрешима в общем случае.
        [1] Дж. Харрисон. Введение в функциональное программирование (5.8 Равенство функций)
        • 0
          В общем случае — да, но когда выражение — это вызов функции без параметров — тут не сложно сравнить ;-) Но в целом согласен, глупая предьява к компилятору.
  • +5
    String.Compare(strings[j], strings[length — j — 1], StringComparison.CurrentCulture) == 0
    Вы сейчас напугали народ длинной конструкцией, забыв сказать, что для сравнения на совпадение через Ordinal можно смело использовать ==. То есть, Ordinal нужен только в тех местах, где используются сравнение больше/меньше, таких как сортировки.
    Декомпилированные исходники
        public static bool operator ==(string a, string b)
        {
          return string.Equals(a, b);
        }
        public static bool Equals(string a, string b)
        {
          if (a == b)
            return true;
          if (a == null || b == null || a.Length != b.Length)
            return false;
          else
            return string.EqualsHelper(a, b);
        }
        private static unsafe bool EqualsHelper(string strA, string strB)
        {
          int length = strA.Length;
          fixed (char* chPtr1 = &strA.m_firstChar)
            fixed (char* chPtr2 = &strB.m_firstChar)
            {
              char* chPtr3 = chPtr1;
              char* chPtr4 = chPtr2;
              while (length >= 10)
              {
                if (*(int*) chPtr3 != *(int*) chPtr4 || *(int*) (chPtr3 + 2) != *(int*) (chPtr4 + 2) || (*(int*) (chPtr3 + 4) != *(int*) (chPtr4 + 4) || *(int*) (chPtr3 + 6) != *(int*) (chPtr4 + 6)) || *(int*) (chPtr3 + 8) != *(int*) (chPtr4 + 8))
                  return false;
                chPtr3 += 10;
                chPtr4 += 10;
                length -= 10;
              }
              while (length > 0 && *(int*) chPtr3 == *(int*) chPtr4)
              {
                chPtr3 += 2;
                chPtr4 += 2;
                length -= 2;
              }
              return length <= 0;
            }
        }
    

    После того, как обнаружилась эта проблема, мы стали в конструкторы всех реализаций IDictionary с ключом строкой передавать удобный StringComparer.Ordinal, что еще немного улучшило производительность.
    Словарём для строк используется по-умолчанию System.Collections.Generic.GenericEqualityComparer<T>, который банально вызывает Equals
    // System.Collections.Generic.GenericEqualityComparer<T>
    public override bool Equals(T x, T y)
    {
    	if (x != null)
    	{
    		return y != null && x.Equals(y);
    	}
    	return y == null;
    }
    

    Equals, как видно из кода реализации string выше, культуру не использует.
  • +3
    По пункту 1 про сравнение строк — это детали реализации. Вполне возможно, что на другой платформе или даже на той же, но после какого нибудь мелкого обновления, результат сравнения производительности радикально изменится. Но в предлагаемом решении хотя бы нет минусов: всегда полезно указать какой именно тип сравнения используется.

    По пункту 2 (про перечислители) — категорически не согласен. Ради очень сомнительной нано-оптимизации (одно выделение ссылочного объекта на весь цикл) нам рекомендуют применять сразу три анти-паттерна (изменяемый тип-значение, конкретный тип вместо интерфейса, более конкретный интерфейс вместо общего). В библиотечных классах перечислитель сделан в виде типа-значения в давние времена, до появления обобщённых (дженерик) типов, чтобы избегать оборачивания (боксинга) в каждой итерации цикла. В наше время это неактуально.

    По пункту 3 про создание делегатов. Опять сомнительная нано-оптимизация. Делегаты ведь фигурируют обычно только один раз, при инициализации. И добавлять для этого отдельный член класса не забыв его правильно инициализировать — нет смысла, мы лишь добавим вероятность появления различных опечаток в коде. И опять же, никто не может быть уверен что в следующем обновлении фреймворка такая оптимизация не будет производится компилятором автоматически.

    Итог: не применяйте оптимизации п.2 и 3 до тех пор, пока профилирование не покажет вам что именно они могут дать заметный прирост производительности.
  • 0
    В библиотечных классах перечислитель сделан в виде типа-значения в давние времена, до появления обобщённых (дженерик) типов, чтобы избегать оборачивания (боксинга) в каждой итерации цикла. В наше время это неактуально.

    Что? Я привел вам List<T>, который появился вместе с generic и которой как раз имеет enumerator структуру. Мне кажется вы невнимательно прочитали в чем проблема. А в целом, насчет того, что применять, а что не применять — мне кажется вы не читали кучу моих предостережений насчет того, когда стоит прибегать к этим советам.
    • 0
      Само перечисление с использование «утиной типизации» было создано до появления обобщённых (дженерик) типов, а созданные позднее библиотечные коллекции просто следуют заложенному паттерну для совместимости со старыми не обобщёнными программами (а не для производительности). Что и порождает до сих пор множественные проблемы (например, вот свежее обсуждение Why does my enumerator not advance?). Уверен, что если бы в языке изначально были обобщённые типы, то «утиная типизация» не использовалась бы для foreach. Там, где нужна экстремальная производительность всегда используют массивы и for, а не перечислители и foreach.

      Может я не заметил каких то предостережений в вашей статье, но вот прямо в начале написано: «На мой взгляд, есть смысл использовать наши хаки в следующих случаях: Вы пишете новый код и решили делать это сразу круто и с экономией.» Использовать анти-паттерны при написании нового (то есть ещё даже не профилированного) кода — по моему вредный совет.
      • 0
        Какая может быть совместимость для абсолютного нового класса? До сих пор во всех коллекциях enumerator делается структурой и сам Липперт пишет:
        Yes, mutable structs are a bad programming practice, but in this case it’s not so bad because the foreach loop is never going to expose the raw enumerator to possible misuse by user code. Sometimes you really do want to avoid collection pressure, so making the enumerator a struct can be a small but measurable win.
        А свежее обсуждение немного притянуто за уши, как мне кажется. Я придерживаюсь мнения, что если ты решил делать что-то нестандартное, то будь добр — изучи матчасть. Так что я не вижу проблем в том, чтобы делать mutable structure.

        Насчет советов: нужно же немного подключать голову. Например в случае нового кода я всё-таки настаиваю, что нужно делать перечислитель для коллекции значимым типом. А для других советов там в начале есть предостережения:
        Если есть возможность без вреда для внешнего кода иметь в качестве параметра конкретную коллекцию, а не интерфейс, то используйте ее, это сможет улучшить производительность foreach
        Если возможности иметь в качестве параметра конкретную коллекцию нет, можно, например, использовать IList и for вместо foreach

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.