Пользователь
0,0
рейтинг
14 июля 2014 в 11:22

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

Мы в своем проекте занимаемся разработкой сервера на C#. Этот сервер должен выдерживать очень высокие нагрузки, по этой причине мы стараемся написать код как можно оптимальней. C# редко ассоциируют с высокой производительностью, но если с умом подходить к разработке, то можно достичь очень даже неплохого уровня.

Одним из недешевых процессов с точки зрения производительности является boxing и unboxing. Напоминалку о том, что это такое, можно найти тут. Недавно я решил посмотреть весь IL код наших проектов и поискать инструкции box и unbox. Нашлось достаточно много участков, boxing'а в которых можно избежать легким движением руки. Все случаи, приводящие к ненужному boxing'у, очевидны, и допускаются по невнимательности в моменты концентрации на функциональности, а не на оптимизации. Я решил выписать наиболее часто встречающиеся случаи, чтобы не забывать о них, а затем автоматизировать их исправление. В данной статье и перечислены эти случаи.

Сразу сделаю ремарку: чаще всего проблемы производительности лежат на более высоком уровне, и прежде чем править весь лишний boxing, нужно привести код к такому состоянию, когда от этого будет толк. Всерьез задумываться о таких вещах как boxing имеет смысл, если вы действительно хотите выжать максимум из C#.

Да простит меня русский язык, но далее в статье я буду использовать неожиданное для него слово «боксинг», чтобы глаз не цеплялся лишний раз в попытке найти строчку кода.

Приступим.

1. Передача value type переменных в методы String.Format, String.Concat и т.п.

Первое место по количеству боксинга держат строковые операции. Благо, в нашем коде это встречалось в основном в форматировании сообщения для исключений. Основное правило для избежания боксинга — это вызывать ToString() у value type переменной перед использованием в методах String.Format или при сложении строк.

То же самое, но в коде. Вместо:

var id = Guid.NewGuid();
var str1 = String.Format("Id {0}", id);
var str2 = "Id " + id;


IL_0000: call valuetype [mscorlib]System.Guid [mscorlib]System.Guid::NewGuid()
IL_0005: stloc.0
IL_0006: ldstr "Id {0}"
IL_000b: ldloc.0
IL_000c: box [mscorlib]System.Guid
IL_0011: call string [mscorlib]System.String::Format(string, object)
IL_0016: pop
IL_0017: ldstr "Id "
IL_001c: ldloc.0
IL_001d: box [mscorlib]System.Guid
IL_0022: call string [mscorlib]System.String::Concat(object, object)


Нужно писать:

var id = Guid.NewGuid();
var str1 = String.Format("Id {0}", id.ToString());
var str2 = "Id " + id.ToString();


IL_0000: call valuetype [mscorlib]System.Guid [mscorlib]System.Guid::NewGuid()
IL_0005: stloc.0
IL_0006: ldstr "Id {0}"
IL_000b: ldloca.s id
IL_000d: constrained. [mscorlib]System.Guid
IL_0013: callvirt instance string [mscorlib]System.Object::ToString()
IL_0018: call string [mscorlib]System.String::Format(string, object)
IL_001d: pop
IL_001e: ldstr "Id "
IL_0023: ldloca.s id
IL_0025: constrained. [mscorlib]System.Guid
IL_002b: callvirt instance string [mscorlib]System.Object::ToString()
IL_0030: call string [mscorlib]System.String::Concat(string, string)


Как мы видим, появляется инструкция constrained вместо box. Здесь написано, что следующий вызов callvirt будет напрямую у переменной, при условии, что thisType это value type, и есть реализация метода. Если же реализации метода нет, то всё равно произойдет боксинг.

Неприятный момент заключается в том, что почти у всех стоит Resharper, который подсказывает, что вызов ToString() лишний.

И еще насчет строк, а точнее их сложения. Иногда встречал код вроде:

var str2 = str1 + '\t';


Есть ложное ощущение, что char без проблем сложится со строкой, но char — это value type, поэтому здесь тоже будет боксинг. В этом случае всё-таки лучше писать так:

var str2 = str1 + "\t";


2. Вызов методов на generic переменных

Второе место по количеству боксинга держат generic методы. Дело в том, что любой вызов метода на generic переменной вызывает боксинг, даже при условии, что выставлен constraint class.

Пример:

public static Boolean Equals<T>(T x, T y)
	where T : class 
{
	return x == y;
}


Превращается в:

IL_0000: ldarg.0
IL_0001: box !!T
IL_0006: ldarg.1
IL_0007: box !!T
IL_000c: ceq


На самом деле здесь не всё так плохо, так как данный IL код будет прооптимизирован JIT'ом, но случай занятный.

Положительным моментом является также то, что для вызова методов на generic переменных используется уже знакомая нам инструкция constrained, а это позволяет вызывать методы на value типах без боксинга. Если же метод работает и с value типами и с reference типами, то, например, сравнение на null лучше писать так:

if (!typeof(T).IsValueType && value == null)
	// Do something


Также существует проблема с оператором as. Типичная практика сразу делать приведение с помощью оператора as вместо проверки на тип и приведения к нему. Но в случае, если у вас может быть value тип, то лучше всё-таки сначала проверить на тип, а потом привести, потому что оператор as работает только с reference типами, и произойдет сначала боксинг, а затем уже вызов isinst.

3. Вызовы методов перечислений

Перечисления в C# сильно печалят. Проблема в том, что любой вызов метода у перечисления вызывает боксинг:

[Flags]
public enum Flags
{
	First = 1 << 0,
	Second = 1 << 1,
	Third = 1 << 2
}

public Boolean Foo(Flags flags)
{
	return flags.HasFlag(Flags.Second);
}


IL_0000: ldarg.1
IL_0001: box HabraTests.Flags
IL_0006: ldc.i4.2
IL_0007: box HabraTests.Flags
IL_000c: call instance bool [mscorlib]System.Enum::HasFlag(class [mscorlib]System.Enum)


Более того, даже метод GetHashCode() вызывает боксинг. Поэтому если вам вдруг нужен хэш код от перечисления, то сначала сделайте приведение к его underlying типу. А еще, если вы вдруг используете перечисление как ключ в Dictionary, то сделайте собственный IEqualityComparer, иначе при каждом вызове GetHashCode() будет боксинг.

4. Перечисления в generic методах

Логичным продолжением пунктов 2 и 3 является желание посмотреть, а как будет вести себя перечисление в generic методе. С одной стороны, если есть реализация метода у value типа, то generic методы умеют вызывать методы интерфейсов у структур без боксинга. С другой стороны, все реализации методов существуют у базового класса Enum, а не у нами созданных перечислений. Напишем небольшой тест, чтобы понять, что происходит внутри.

Код теста
public static void Main()
{
	Double intAverageGrow, enumAverageGrow;
	Int64 intMinGrow, intMaxGrow, enumMinGrow, enumMaxGrow;

	var result1 = Test<Int32>(() => GetUlong(10), out intAverageGrow, out intMinGrow, out intMaxGrow);
	var result2 = Test<Flags>(() => GetUlong(Flags.Second), out enumAverageGrow, out enumMinGrow, out enumMaxGrow);

	Console.WriteLine("Int32 memory change. Avg: {0}, Min: {1}, Max: {2}", intAverageGrow, intMinGrow, intMaxGrow);
	Console.WriteLine("Enum  memory change. Avg: {0}, Min: {1}, Max: {2}", enumAverageGrow, enumMinGrow, enumMaxGrow);

	Console.WriteLine(result1 + result2);
	Console.ReadKey(true);
}

public static UInt64 GetUlong<T>(T value)
	where T : struct, IConvertible
{
	return value.ToUInt64(CultureInfo.InvariantCulture);
}

public static UInt64 Test<T>(Func<UInt64> testedMethod, out Double averageGrow, out Int64 minGrow, out Int64 maxGrow)
{
	GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;

	var previousTotalMemory = GC.GetTotalMemory(false);
	Int64 growSum = 0;
	minGrow = 0;
	maxGrow = 0;

	UInt64 sum = 0;
	for (var i = 0; i < 100000; i++)
	{
		sum += testedMethod();

		var currentTotalMemory = GC.GetTotalMemory(false);
		var grow = currentTotalMemory - previousTotalMemory;
		growSum += grow;

		if (minGrow > grow)
			minGrow = grow;

		if (maxGrow < grow)
			maxGrow = grow;

		previousTotalMemory = currentTotalMemory;
	}

	averageGrow = growSum / 100000.0;

	return sum;
}



Результат:

Int32 memory change. Avg: 0, Min: 0, Max: 0
Enum  memory change. Avg: 3,16756, Min: -2079476, Max: 8192


Как мы видим, с перечислениями и тут всё не слава богу: происходит боксинг при каждом вызове метода ToUInt64(). Но зато наглядно видно, что вызов интерфейсного метода у Int32 не вызывает никакого боксинга.

А под конец и отчасти как вывод хочется добавить, что value типы здорово помогают поднять производительность, но нужно внимательно следить за тем, как они используются, иначе в результате боксинга главное их преимущество будет нивелировано.
В следующей статье мне хотелось бы рассказать о местах, где неочевидным образом находятся глобальные точки синхронизации, и как их обходить. Stay tuned.
Александр Пак @alekspak
карма
21,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

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

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

  • 0
    Еще один способ избежать боксинга/анбоксинга в дженерик методах это TypedReferences(работают в моно с 2.10)

    static void foo(ref T value)
    {

    //This is the ONLY way to treat value as int, without boxing/unboxing objects
    if (value is int) __refvalue(__makeref(value), int) = 1;
    else value = default(T); }

    }
    • +1
      Мне кажется в этом случае, когда известен тип и вы гонитесь за такой производительностью, то проще написать отдельный метод под Int32.
  • +4
    и насколько это улучшило производительность?
    • +1
      Где-то на 10% уменьшилось количество блокировок. У нас в одном очень часто вызываемом месте стоял HasFlag().
      • +1
        А где возникали блокировки? При выделении памяти в куче? Я не пишу под Mono, но на сколько помню, там для каждого потока в куче выделяется фрейм, и блокировка возникает только в том случае, если необходимо выделить память под новый объект, не помещающийся во фрейм. Опять же не уверен, но я думаю, что при вызове HasFlag на фрейме будет аллоцироваться один объект, и затем при выходе из метода он будет сразу удаляться (зависит от конкретной реализации VM, но в Mono, кажется, так и есть), таким образом объем фрейма будет постоянным. Или я не прав, и в Mono всё хуже? :)
        • 0
          На моно есть boehm и sgen (может еще что-то есть, но я не знаю). Это два разных GC по идеологии. Мы пользуемся sgen (это generational GC), а то, что вы описали это boehm. Можно изучить вопрос, но даже если так, то в любом случае sgen себя показывает лучше чем boehm в наших условиях.
        • 0
          Кстати, фрейм под поток? Аллоцировать объект на фрейме и очищать после выхода из метода? Мне кажется вы описываете стук.

          А под моно происходит вот что:

          #define LOCK_GC do { mono_mutex_lock (&gc_mutex); MONO_GC_LOCKED (); } while (0)

          LOCK_GC;
          res = mono_gc_alloc_obj_nolock (vtable, size);

          Но я почти уверен, что что-то подобное и в .NET происходит, потому что мы там тоже видим lock.
          • 0
            *стэк конечно
          • 0
            Нет, вот что я имел ввиду:

            Allocation

            In a classic semi-space collector allocation is done in a linear fashion by pointer bumping, i.e. simply incrementing the pointer that indicates the start of the current semi-space’s region that is still unused by the amount of memory that is required for the new object.

            In a multi-threaded environment like Mono using a single pointer would introduce additional costs because we would have to do at least a compare-and-swap to increment it, potentially looping because it might fail due to contention. The standard solution to this problem is to give each thread a little piece of the nursery exclusively from which it can bump-allocate without contention. Synchronization is only needed when a thread has filled up its piece and needs a new one, which should be far less frequent. These pieces of nursery are called “thread local allocation buffers”, or “TLABs” for short.


            Использовал неправильный термин :)
            • 0
              Но да, похоже, что при выходе из метода память не очищается. Тогда понятно, откуда могут быть блокировки. :)
            • +1
              Да, всё так. Но как всегда теория выглядит хорошо, а в реальности не всё так безоблачно. Исходники открытые, можете почитать. Интересное чтиво :)
        • 0
          При выходе из меотда сборок не происходит. И память очевидно не освобождается.И в .NET и в Mono.
          Вы путаете аллокацию value и reference типов.

      • 0
        блокировок чего? GC?
        • 0
          Вообще, я имею в виду все блокировки, то есть когда мы сваливаемся в kernel lock и поток спит. Но в данном случае — да, проблема была в GC.
  • +4
    А профилировщиками или бенчмарками пункты 1-2-3 меряли?

    Мне просто хочется посмотреть в своём проекте, насколько большой вклад дают эти вещи в тормоза в целом, и имеет ли смысл срочно ими заморочиться. Хорошо бы знать, как лишний боксинг будет выглядеть в профайлере, например встроенном или джетбрейновском.
  • +3
    Я раньше постоянно из эстетических соображений переписывал стандартные конструкции вида "(a & b) == b" на a.HasFlag(b). До тех пор, пока не запустил профайлер и не обнаружил миллионы ненужных боксов. Ясно, что это короткоживущие объекты, и они уничтожаются практически сразу, но всё-равно я не понимаю, почему разработчики CLR сделали такую кривую реализацию этого метода. Лучше бы вообще не делали.
  • 0
    Здорово! Спасибо за статью. Очень был удивлен по поводу enum-ов. Все время старался использовать именно их в качестве ключа для Dictionary из предположения «легковесности» (по крайней мере мне так казалось) вычисления GetHashCode-а.
  • 0
    Отвечаю сразу на два вопроса по тому, насколько это поможет в ваших проектах. Сложно сказать, я же не видел ваш код. Вообще, как я написал вначале, проблемы производительности обычно лежат на уровень выше и надо просто запускать профайлер и решать одну проблему за другой. Насчет того, как найти в профайлере. В профайлере можно часто увидеть различные value типы в списках объектов, живущих на куче. Если их очень много, и они выходят на первые строки, то надо начинать задумываться. Мы смотрели блокировки и получили такой stack trace (но это я уже забегаю в тему следующей статьи):

    stack
    #4 0x00007ffeda83cc72 in __lll_lock_wait () from /lib/libpthread.so.0
    #5 0x00007ffeda838179 in _L_lock_953 () from /lib/libpthread.so.0
    #6 0x00007ffeda837f9b in pthread_mutex_lock () from /lib/libpthread.so.0
    #7 0x00000000005f9269 in mono_gc_alloc_obj (vtable=vtable(«System.Int32»), size=20) at sgen-alloc.c:468
    #8 0x00000000005b4b4d in mono_object_new_alloc_specific (vtable=vtable(0x2)) at object.c:4481
    #9 0x00000000005b55e8 in mono_object_new_specific (vtable=vtable(«System.Int32»)) at object.c:4472
    #10 0x00000000005379a9 in ves_icall_System_Enum_get_value (this=0x7ffed94ac8f0) at icall.c:3093
    #11 0x00000000415197dd in (wrapper managed-to-native) System.Enum:get_value (param0=<type 'exceptions.RuntimeError'>
    Cannot access memory at address 0x190000000002b2
    <type 'exceptions.RuntimeError'>
    Cannot access memory at address 0x190000000002b2
    140732543977712) at :668
    #12 0x0000000041643994 in System.Enum:HasFlag (this=<type 'exceptions.RuntimeError'>
    Cannot access memory at address 0x190000000002b2
    <type 'exceptions.RuntimeError'>
    Cannot access memory at address 0x190000000002b2
    140732543977712, flag=<type 'exceptions.RuntimeError'>
    Cannot access memory at address 0x190000000002b2
    <type 'exceptions.RuntimeError'>
    Cannot access memory at address 0x190000000002b2
    140732543977736) at /root/mike/mono/mcs/class/corlib/System/Enum.cs:1991
    • 0
      А каким профайлером пользуетесь, если не секрет?
      • 0
        RedGate, студийным, Concurrency Visualizer for Visual Studio, профайлером для mono, gdb. Еще пробовали Intel® VTune™ Amplifier XE 2013 — прикольная штука.
  • +2
    1. Первое правило оптимизации: Никогда не занимайся оптимизацией без профилирования!
    2. Второе правило оптимизации: Никогда не занимайся оптимизацией без профилирования!
    3. Третье правило оптимизации: Никогда не занимайся оптимизацией без профилирования!
    4. Если boxing/unboxing стал решающим, значит сервер и так уже супероптимизирован и форматирование строк, например, там вовсе не используется
    • +1
      Да, как я написал, форматирование строк у нас всплыло в основном в создании различных исключений, а в этом случае боксинг не самая большая ваша проблема. Перед тем, как дойти до оптимизирования боксинга мы не один месяц правили другие участки кода.
      • 0
        Это стоит добавить в статью. Я пока не видел ситуацию чтобы что либо в ToString садило перфоманс. Если такое будет — таких программистов надо за кольцевую вывозить и долго бить. Если уже кто то яростно клеит строки и это доменная задача — там понятно что будут не ToString использовать.

        А то сейчас начнется, в каждом проекте будет каша в ToString и ребята с круглыми глазенками будут доказывать что дядя на хабре писал что так медленнее.
        Если для value типов делать ToString то и строки форматирования надо в каждый отдельный передавать, это немного убивает смысл ToString с единой строкой форматирования.
        • +1
          Сразу сделаю ремарку: чаще всего проблемы производительности лежат на более высоком уровне, и прежде чем править весь лишний boxing, нужно привести код к такому состоянию, когда от этого будет толк.


          Я же вначале написал, не надо кидаться в такие крайности, если у вас нет с этим проблем. Просто, если код писали не совсем криво, то мало будет мест, которые можно исправить и сразу +200% к производительности. Наступает момент, когда тут немного, там немного и получили +10%. Мелочь, а приятно.
  • 0
    Утверждается, что боксинг и оператор as дороже, чем два вызова приведения типа: is + (T)?
    • 0
      Скажем так: в конкретно нашем случае, мы лучше сделаем несколько лишних операций, чем выделим лишнюю память, которая влияет на GC и тем самым может вызвать блокировку потока. То есть мы пока еще не грузим процессор в 100% всё время, потому что есть блокировки, немалая часть из которых вызваны из-за GC.
  • 0
    В WPF, который делает дофига боксов при доступе к свойствам, есть вот такая вещь:

        public static class BooleanBoxes
        {
            public static readonly object True = true;
            public static readonly object False = false;
    
            [Pure]
            public static object Box(bool value)
            {
                Contract.Ensures(Contract.Result<object>() != null);
    
                return value ? True : False;
            }
        }
    

    Она internal, но можно добавить код в свой проект и тоже использовать.
    • 0
      Да, знаю. У нас тоже есть в некоторых местах такие штуки. А еще вот так извращаемся.
      • 0
        А как это помогает от боксов?
        • 0
          Позволяет в одной коллекции хранить объекты различного типа без боксинга. В WPF значения хранятся в EffectiveValueEntry, которые структуры, но значения внутри себя хранят в Object поле. Если количество различных типов заранее известно, то можно использовать union, чтобы не боксить, но при этом хранить их в одном месте.
  • +2
    При использовании ToString в сочетании с Format есть одна вещь, про которую легко забыть — если форматирование делается не с CurrentCulture (а, например, с InvariantCulture), то его надо передавать и в Format, и во все ToString. Хотя имхо вообще культуру лучше явно передавать всегда там, где есть такая возможность, и настроить варнинги соответствующим образом.

    >> Если же метод работает и с value типами и с reference типами, то, например, сравнение на null лучше писать так

    Сравнение с null для value-типов в языке определено отдельно, и не приводит к реальному боксингу. Интереса ради можете попробовать сравнить какой-нибудь Int32 с null в не-generic коде, и посмотреть на IL.

    Вообще, тут главное помнить, что любой generic метод, если параметром-типом ему передать value-тип, получит отдельную реализацию на уровне JIT, где конкретный T известен — и оптимизатор там порезвится вволю. Из моих личных экспериментов, генерируемый ассемблерный код практически всегда эквивалентен тому, что получается при ручной подстановке.

    >> оператор as работает только с reference типами

    Это не так — он работает и с nullable value-типами. К вашему случаю это, правда, не относится поскольку нельзя написать T?, если нет struct constraint (кстати, это, наверное, наиболее косячная деталь в реализации nullable в CLR). Но поскольку у value-типа будет своя инстанциация дженерика, я почти стопроцентно уверен, что JIT-оптимизатор выкинет в ней и box, и isinst, и просто подставит там константу. Хотя это надо проверить.
    • 0
      То, что оптимизатор многие вещи убирает я уже понял, но так как я пишу под моно, то я предпочитаю некоторые вещи писать явно и не зависеть от реализации. А насчет nullable, так это вообще одно большое исключение из правил.
      • +1
        Кстати, это была бы сама по себе интересная тема — сравнить выход JIT у Mono и .NET на таких вот моментах.
        • 0
          Да, интересная тема. Скорей всего займусь, тем более, что при обнаружении очевидных проблем, можно будет самому исправить в коде моно и попробовать влить в основной репозиторий.

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