Правило 16 байт: развенчиваем миф о производительности структур в C#

в 7:00, , рубрики: .net, benchmark, C#

По умолчанию, при передаче в метод или при возврате из метода, экземпляры значимых типов копируются, когда как экземпляры ссылочных типов передаются по ссылке. В 2008 году была выпущена книга «Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable .NET Libraries»‎. В этой книге рекомендовалось не использовать структуры размером больше 16 байт, поскольку, очевидно, структуры большего размера копируются медленнее. Несмотря на то, что прошло уже 16 лет, в сообществе C# разработчиков до сих пор популярно мнение, что производительность структур размером больше 16 байт хуже. Даже Google на запрос «recommended structure size c#» говорит, что это не более 16 байт. В этой статье мы попробуем докопаться до правды.

Дисклеймер

Информация в этой статье верна только при определённых условиях. Я допускаю, что бенчмарк может показать другие результаты на другом ПК, с другим ЦП, с другим компилятором или при использовании других классов и структур. Всегда проверяйте ваш код на конкретно вашем железе и не полагайтесь лишь на статьи из интернета. :)

Бенчмарк

Код бенчмарка очень прост. Он содержит структуры и классы размером от 4 до 160 байт, с шагом 4 байта.

public record struct Struct04(int Param);

public record struct Struct160(
    int Param1, int Param2, int Param3, int Param4, 
    int Param5, int Param6, int Param7, int Param8, 
    int Param9, int Param10, int Param11, int Param12, 
    int Param13, int Param14, int Param15, int Param16, 
    int Param17, int Param18, int Param19, int Param20, 
    int Param21, int Param22, int Param23, int Param24, 
    int Param25, int Param26, int Param27, int Param28,
    int Param29, int Param30, int Param31, int Param32, 
    int Param33, int Param34, int Param35, int Param36, 
    int Param37, int Param38, int Param39, int Param40);

public record struct Class04(int Param);

public record class Class160(
    int Param1, int Param2, int Param3, int Param4, 
    int Param5, int Param6, int Param7, int Param8, 
    int Param9, int Param10, int Param11, int Param12,
    int Param13, int Param14, int Param15, int Param16, 
    int Param17, int Param18, int Param19, int Param20, 
    int Param21, int Param22, int Param23, int Param24, 
    int Param25, int Param26, int Param27, int Param28,
    int Param29, int Param30, int Param31, int Param32, 
    int Param33, int Param34, int Param35, int Param36, 
    int Param37, int Param38, int Param39, int Param40);

Для каждой структуры и класса есть соответствующий метод, который из параметра типа int cоздаёт соответствующий экземпляр и возвращает его.

public static Struct20 GetStruct20(int value) => new(value, value, value, value, value);

public static Class20 GetClass20(int value) => new(value, value, value, value, value);

И, самое главное, есть непосредственно бенчмарк методы, каждый из которых создаёт список структур или классов. Размер списка – 1000 элементов.

public int Iterations { get; set; } = 1000;

private static void Add<T>(List<T> list, T value) => list.Add(value);

[Benchmark(Baseline = true)]
public List<Struct04> GetStruct4()
{
    var list = new List<Struct04>(Iterations);
    for (int i = 0; i < Iterations; i++) Add(list, GetStruct04(i));
    return list;
}

[Benchmark(Baseline = true)]
public List<Class04> GetClass4()
{
    var list = new List<Class04>(Iterations);
    for (int i = 0; i < Iterations; i++) Add(list, GetClass04(i));
    return list;
}

Для замеров производительности я использовал библиотеку BenchmarkDotNet.

Результаты

Замеры времени

Как видно из графика, создание структур размером не более 64 байт происходит быстрее, чем создание экземпляров классов.

Абсолютное (слева) и относительное (справ) время выполнение бенчмарка.

Абсолютное (слева) и относительное (справ) время выполнение бенчмарка.

Если приблизить график, то можно заметить, что использование структур быстрее на 40–70%.

Абсолютное (слева) и относительное (справ) время выполнение бенчмарка.

Абсолютное (слева) и относительное (справ) время выполнение бенчмарка.

Чтобы понять, почему CLR ведёт себя именно так, нужно углубиться в код, в который компилируется C#. Если взглянуть на IL код методов GetStruct64 и GetStruct128, то можно заметить, что отличия есть только в именах.

IL код методов GetStruct64 и GetStruct128

IL код методов GetStruct64 и GetStruct128

Значит причина в чём-то другом и нужно углубиться ещё сильнее, а именно в машинной код, генерируемый JIT‑компилятором. К счастью, в библиотеке BenchmarkDotNet есть нужный для этого функционал. Сравнивая машинный код, можно заметить, что метод GetStruct64(int value) не вызывается. Вместо этого, выполняется множество операций mov (mov перемещает данные между регистрами и памятью). Получается, что эти операции инициализируют Struct64. Но куда же делся вызов метода GetStruct64(int value)? JIT‑компилятор оптимизировал наш код и заменил вызов метода его телом. Эта оптимизация известна под названием function inlining или method inlining. Благодаря этой оптимизации, CLR не пришлось копировать экземпляр структуры при возврате из метода.

Машинный код методов GetStruct64 и GetStruct128

Машинный код методов GetStruct64 и GetStruct128

Другое интересное наблюдение заключается в том, что память для структур в стеке не выделялась совсем. Хотя для опытных C# разработчиков это вряд ли станет открытием. JIT‑компилятор снова оптимизировал код таким образом, что использовались только регистры, даже для структур размером 128 байт и больше (спасибо регистрам AVX-256).

Замеры памяти

Графики использования памяти плавные и без резких скачков. Это говорит о том, что разница между количеством выделенной памяти для классов и структур постоянна. Код, использующий структуры размером не более 64 байт, потребляет памяти на 27–87% меньше.

Абсолютное (слева) и относительное (справ) значение выделенной памяти за 1 операцию.

Абсолютное (слева) и относительное (справ) значение выделенной памяти за 1 операцию.

Заключение

В этой статье мы убедились, что использование структур размером больше 16 байт не ухудшает производительность. Сейчас этой границей является размер 64 байта. JIT‑компилятор достаточно умный, чтобы оптимизировать код во время выполнения, поэтому в определённых условиях структуры лучше классов, как с точки зрения времени выполнения, так и с точки зрения потребления памяти.

Автор:
alexeyfv

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js