Улучшаем производительность в .NET Core (по опыту бинарного сериализатора Hagar)

Posted by VladymyrL on Saturday, January 26, 2019
Last Modified on Monday, February 11, 2019

TOC

От переводчика: рекомендую к Вашему внимаию перевод статьи Reuben Bond-а Performance Tuning for .NET Core. Тут, пожалуй, описаны почти все последние инструменты dotnet, которые помогут вашим приложениям достичь новых высот производительности. Статья интересна ещё и ссылками, которые просто must read!

Кто-то из вас, возможно, знает, что я трачу все свое время на то, чтобы завершить разработку новой .NET библиотеки сериализации. Сериализатор, должен вам сказать, оказался очень сложным в реализации. Он должен быть надежным, гибким и быстрым, безупречным в работе. Я не буду убеждать вас, что библиотеки сериализации должны быть быстрыми - в этом посте это само собой разумеется. Вот несколько советов из моего опыта по оптимизации производительности Hagar. Большая часть этих советов применима к другим типам библиотек или приложений.

Согласно канона пост о производительности должен иметь минимум воды и сразу переходить к сути, поэтому этот пост посвящен советам, которые могут оказаться полезными и вам, в плане акцентирования внимания на том, на что нужно обратить внимание. Напишите мне в Twitter, если Вам что-то неясно или у вас есть что добавить.

Максимизируйте встраивание кода

Встраивание - это техника оптимизации компиляторов, при которой тело метода копируется в место вызова, чтобы мы могли избежать затрат на вызов, передачу параметров и сохранение/восстановление регистров. В дополнение к экономии этих затрат, встраивание является обязательным требованием для других оптимизаций. Roslyn (компилятор C#) не встраивает код. Это ответственность JIT, как и большинство оптимизаций.

Используйте статические throw хэлперы

Недавнее изменение, которое включало значительный рефакторинг, добавило около 20 нс к продолжительности вызова для эталонной сериализации, увеличив время от ~ 130 нс до ~ 150 нс (что для кода библиотеки сериализации является существенным).

Как оказалось, виновным стало добавление оператора throw в этот вспомогательный метод:

public static Writer<TBufferWriter> CreateWriter<TBufferWriter>(
    this TBufferWriter buffer,
    SerializerSession session) where TBufferWriter : IBufferWriter<byte>
{
    if (session == null) throw new ArgumentNullException(nameof(session));
    
    return new Writer<TBufferWriter>(buffer, session);
}

Когда метод содержит оператор throw, JIT не будет встраивать его. Обычным трюком для решения этой проблемы - это добавить статический метод «throw хэлпер», который сделает за вас грязную работу, поэтому конечный результат выглядит следующим образом:

public static Writer<TBufferWriter> CreateWriter<TBufferWriter>(
    this TBufferWriter buffer,
    SerializerSession session) where TBufferWriter : IBufferWriter<byte>
{
    if (session == null) ThrowSessionNull();
    return new Writer<TBufferWriter>(buffer, session);

    void ThrowSessionNull() => throw new ArgumentNullException(nameof(session));
    
}

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

Минимизируйте виртуальные/интерфейсные вызовы

Виртуальные вызовы медленнее, чем прямые (прим. пер. см. таблицу сравнения ). Если вы пишете систему, критичную к производительности, есть большая вероятность того, что в профилировщике появится информация о накладных расходах при виртуальных вызовах методов. для каждого виртуального вызова потребуются непрямое обращение (прим. пер. - посредством таблицы виртуальных методов).

Девиртуализация - это особенность многих JIT-компиляторов, и RyuJIT не является исключением. Однако это сложная задача, и существует не так много случаев, когда RyuJIT может доказать (сам себе), что метод может быть девиртуализирован и, следовательно, стать кандидатом на встраивание. Вот несколько общих советов, чтобы воспользоваться преимуществами девиртуализации, но я уверен, что есть и другие (так что дайте мне знать, если они у вас есть).

  • Отметьте классы как sealed по умолчанию. Когда класс / метод помечен как sealed, RyuJIT может принять это во внимание и, вероятно, сможет встроить вызов метода.
  • Отметьте методы override как sealed если это возможно.
  • Используйте конкретные типы вместо интерфейсов. Конкретные типы дают JIT больше информации, так что у него больше шансов быть в состоянии встроить ваш вызов.
  • Создавайте и используйте не sealed объекты в одном и тем же методе (вместо использования метода create). RyuJIT может девиртуализировать вызовы не sealed методы, только когда тип определенно известен, например сразу после создания.
  • Используйте ограничения обобщений (generic-ов) для полиморфных типов, чтобы они смогли стать специализированными для использования конкретного типа, а вызовы интерфейса могли стать девиртуализированы.

В Hagar наш основной тип writer-а определяется следующим образом:

public ref struct Writer<TBufferWriter> where TBufferWriter : IBufferWriter<byte>
{
    private TBufferWriter output;
    
    // --- etc ---

Всем вызовам методов на output в IL, которые генерирует Roslyn, будет предшествовать constrained инструкция, которая сообщает JIT, что вместо виртуального вызова/вызова через интерфейс можно выполнить вызов конкретного метода, определенного в TBufferWriter. Это помогает с девиртуализацией. Все вызовы методов, определенных в output , в результате успешно девиртуализируются. Вот обсуждение CoreCLR Энди Айерса из команды JIT, в котором подробно описывается текущая и будущая работа по девиртуализации.

Сокращайте аллокации памяти

Сборщик мусора в .NET - замечательное инженерное решение. GC допускает алгоритмическую оптимизацию для некоторых структур данных без блокировки, а также устраняет целые классы ошибок и облегчает когнитивную нагрузку разработчика. Учитывая все обстоятельства, сборка мусора является чрезвычайно успешной техникой для управления памятью.

Однако, хотя GC является мощной рабочей лошадкой, он помогает снизить нагрузку не только потому, что это означает, что ваше приложение будет реже приостанавливать сбор (и в целом меньше времени ЦП будет отводиться работе GC), но и потому, что выделение рабочего набора данных выгоден для локальности кэша.

Основное правило для распределений заключается в том, что они должны либо умереть в первом поколении (Gen0), либо жить вечно в последнем (Gen2).

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

Для получения дополнительной информации о GC .NET см. Серию постов в блоге Мэтта Уоррена «Изучение работы сборщика мусора» и книгу доступную по предзаказу Конрада Кокоса «Pro .NET Memory Management here». Также ознакомьтесь с его фантастическим постером по управлению памятью .NET, это отличный справочник.

Используйте пулы буферов / объектов

Сама Hagar не управляет буферами, а переносит ответственность на пользователя. Это может показаться обременительным, но это не так, поскольку он совместим с System.IO.Pipelines. Таким образом, мы можем воспользоваться преимуществами высокопроизводительного пула буферов, которые по умолчанию обеспечивает Pipe при помощи System.Buffers.ArrayPool<T>.

Вообще говоря, повторное использование буферов позволяет вам создавать гораздо меньше работы для сборщика мусора - ваши пользователи будут благодарны. Не пишите свой собственный буферный пул, если только вам действительно не нужно - эти времена прошли.

Избегайте упаковок

Везде, где возможно, не упаковывайте значимые типы, приводя их к ссылочному типу. Это распространенный совет, но он требует некоторого внимания при разработке вашего API. В Hagar определения интерфейсов и методов, которые могут принимать типы значений, сделаны общими, чтобы их можно было специализировать для конкретного типа и избежать затрат на упаковку / распаковку. В результате, нет упаковки в горячем пути. Упаковка все еще присутствует в некоторых случаях, таких как форматирование строки для методов исключения. Эти конкретные аллокации при упаковке могут быть удалены явным образом вызывая .ToString().

Сократите аллокации замыканий

Аллоцируйте замыкания только один раз и сохраните результат для повторного использования. Например, зачастую передавая делегат ConcurrentDictionary<K, V>.GetOrAdd, вместо того, чтобы записывать делегат в виде встроенной лямбды, обьявляйте его как поле класса. Вот пример из опционального пакета поддержки ISerializable в Hagar:


private readonly Func<Type, Action<object, SerializationInfo, StreamingContext>> createConstructorDelegate;

public ObjectSerializer(SerializationConstructorFactory constructorFactory)
{
    // Other parameters/statements omitted.
    
    this.createConstructorDelegate = constructorFactory.GetSerializationConstructorDelegate;
}

// Later, on a hot code path:

var constructor = this.constructors.GetOrAdd(info.ObjectType, this.createConstructorDelegate);

Минимизируем копирование.

.NET Core 2.0 и 2.1 и последние версии C# добились значительных успехов, позволив разработчикам библиотек исключить копирование данных. Наиболее заметным улучшением является Span<T>, но также стоит упомянуть модификатор параметра in и readonly struct .

Используйте Span что-бы избежать выделения массивов и копирования данных.

Span и его друзья предоставят вам гигантский выигрыш в производительности для .NET, особенно для .NET Core, где они используют оптимизированное представление для уменьшения своего размера. Это улучшение потребовало добавления поддержки GC для внутренних указателей. Внутренние указатели - это управляемые ссылки, которые указывают в пределах массива, в отличие от возможности указывать только на первый элемент и, следовательно, требуют дополнительного поля, содержащего смещение в массиве. Для получения дополнительной информации о Span<T> и друзьях, прочитайте статью Стивена Тауба «Все о Span: исследование нового .NET Mainstay».

Hagar широко использует Span<T> потому что позволяет нам дешево создавать представления для небольших секций в больших буферах для работы. На эту тему было написано достаточно, так что я не буду больше писать здесь.

Передавайте структуры через ref для минимизации копий в стеке.

Hagar использует две основные структуры: Reader и Writer<TOutputBuffer>. Каждая из этих структур содержит несколько полей и передается почти каждому вызову по пути вызова сериализации/десериализации.

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

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

Избегайте защитных копий.

Roslyn иногда проводит дополнительную работу, чтобы гарантировать языковые инварианты. Когда struct сохраняется в readonly поле, компилятор вставит инструкцию защитного копирования поля, прежде чем структура окажется втянута в операцию, которая не гарантирует неизменяемость структуры. Как правило, это означает вызов метода, определенного в самом типе структуры, поскольку передача структуры в качестве аргумента методу, определенному в другом типа, уже требует копирования структуры в стек (хотя такое поведение всё ещё возможно если структура передана при помощи ref или in ).

Этого защитного копирования можно избежать, если структура определена как readonly struct, эта возможность появилась в C# версии 7.2, и включается добавлением 7.2 в ваш файл csproj.

Иногда лучше опустить модификатор readonly в неизменяемом поле, если вы не можете определить его как readonly struct.

См. Пример библиотеки Джона Скита NodaTime. В этом PR Джон сделал большинство структур readonly и поэтому смог добавить модификатор readonly к полям, содержащим эти структуры, не оказывая негативного влияния на производительность.

Уменьшайте ветвление и неправильное предсказание ветвления.

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

Когда он угадывает правильно, этот процесс прогнозирования обеспечивает существенное ускорение. Однако, когда он неправильно прогнозирует ветвь (цель перехода), он должен выбросить всю работу, выполненную в инструкциях обработки после ветвления, и повторно заполнить конвейер инструкциями из правильной ветки перед продолжением выполнения.

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

Одна из удачных стратегий устранения ветки - заменить ее поиском, тогда алгоритм может быть реализован без ветвления. Иногда аппаратные свойства могут использоваться для устранения ветвления.

Прочие советы.

  • Избегайте LINQ. LINQ хорош в коде приложения, но редко относится к горячим путям в коде библиотеки / фреймворка. LINQ сложно оптимизировать в JIT ( IEnumerable<T>…) и, как правило, требует дополнительных аллокаций.

  • Используйте конкретные типы вместо интерфейсов или абстрактных типов. Как это было упомянуто выше в контексте встраивания, но это даёт и другие преимущества. Наиболее распространенным примером является ситуация когда вы выполняете итерацию по List<T>. Не лучшим решением будет привести этот список к IEnumerable<T> (например, используя LINQ или передавая его методу в качестве параметра IEnumerable<T>). Причина этого заключается в том, что при перечислении по списку с использованием foreach используется не аллоцирующий List<T>.Enumerator, но когда список приведен к IEnumerable<T> , для этой структуры должна призвестись упаковка в IEnumerator<T> для foreach .

  • Рефлексия является исключительно полезным в коде библиотеки, но она обязательно убьет вас, как только вы даете ей шанс. Кэшируйте результаты рефлексии, рассмотрите возможность создания делегатов для методов доступа, использующих IL или Roslyn, или, что еще лучше, используйте существующую библиотеку, такую как Microsoft.Extensions.ObjectMethodExecutor.Sources, Microsoft.Extensions.PropertyHelper.Sources, или FastMember.

Оптимизации специфичные для библиотек.

Оптимизируйте сгенерированный код.

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

Избегайте виртуальных вызовов, пропуская поиск кодеков для известных типов.

Когда сложные объекты содержат хорошо известные поля, такие как int, Guid, string, генератор кода будет напрямую вставлять вызовы кодеков, закодированных вручную для этих типов, вместо вызова в CodecProvider для получения IFieldCodec<T> для этого типа. Это позволяет JIT встроить эти вызовы и избежать непрямых вызовов из-за модификатора virtual или интерфейса.

(Не реализовано) Специализируйте дженерик типы во время выполнения.

Как было описано ранее, применив эту технику компилятор сможет генерировать код, который применит специализирует generic во время выполнения.

Вычисляйте заранее значения констант, что-бы устранить ветвления.

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

Следовательно, зачастую мы можем избежать работы, необходимой для вычисления значения заголовка, и можем напрямую встроить его в код как константу. Это сохраняет ветвление и, как правило, устраняет большое количество кода IL.

Выберите подходящие структуры данных

Один из больших недостатков производительности, который имеет Hagar, по сравнению с другими сериализаторами, такими как protobuf-net (в конфигурации по умолчанию) MessagePack-CSharp это то что он поддерживает циклические графы и поэтому должен отслеживать объекты по мере их сериализации, чтобы циклы объектов не терялись при десериализации. Когда это было впервые реализовано, основной структурой данных был Dictionary<object, int>. При первоначальном сравнительном анализе стало ясно, что отслеживание ссылок оказывается наибольшей сложностью. В частности, очистка словаря между сообщениями была дорогой. Вместо этого, переключаясь на массив структур, затраты на индексацию и обслуживание коллекции в значительной степени устраняются, и отслеживание ссылок больше не улавливается в бенчмарками. У этого есть и обратная сторона: для больших графов объектов вполне вероятно, что новый подход окажется медленнее. Если это окажется проблемой, мы можем решить её динамическим переключением между реализациями.

Выберите подходящие алгоритмы

Hagar тратит много времени на кодирование/декодирование целых чисел переменной длины, часто называемых varints, чтобы уменьшить размер пейлоада (который может стать более компактным для хранения/транспортировки). Многие бинарные сериализаторы используют эту технику, включая Protocol Buffers. Даже .NET BinaryWriter использует эту кодировку. Вот фрагмент из исходника :

protected void Write7BitEncodedInt(int value) {

    // Write out an int 7 bits at a time.  The high bit of the byte,
    
    // when on, tells reader to continue reading more bytes.
    
    uint v = (uint) value;   // support negative numbers
    
    while (v >= 0x80) {
        Write((byte) (v | 0x80));
        v >>= 7;
    }
    Write((byte)v);
}

Глядя на этот исходник, я хочу отметить, что кодирование ZigZag может быть более эффективным для целых чисел со знаком, которые содержат отрицательные значения, а не приведение к uint.

VarInts в этих сериализаторах использует алгоритм Little Endian Base-128 или LEB128, который кодирует до 7 бит на байт. Он использует старший значащий бит каждого байта, чтобы указать, следует ли другой байт (1 = да, 0 = нет). Это простой формат, но он может быть не самым быстрым. Может оказаться, что PrefixVarint быстрее. С PrefixVarint все эти 1 из LEB128 записываются одним выстрелом в начале полезной нагрузки. Это может позволить нам использовать аппаратные встроенные функции, чтобы улучшить скорость этого кодирования и декодирования. Перемещая информацию о размере на передний план, мы также можем читать больше байтов за раз из полезной нагрузки, уменьшая внутренние расчёты и повышая производительность. Если кто-то захочет реализовать это в C#, я с удовольствием возьму PR, если он окажется быстрее.


Надеюсь, вы нашли что-то полезное в этом посте. Дайте мне знать, если что-то неясно или вам есть что добавить. С тех пор как я начал писать это, я переехал в Редмонд и официально присоединился к Microsoft в команде Орлеана, работая над некоторыми очень захватывающими вещами.


comments powered by Disqus