Введение

Во время разработки игр объем данных, обрабатываемых игрой, увеличивается. Художники создают новые ресурсы, программисты пишут код, а геймдизайнеры добавляют больше конфигураций и настроек в игру. Когда размер этих конфигураций превышает десятки мегабайт, производительность даже самых тривиальных компонентов, таких как JSON-парсер конфигураций, становится критичной. В этой статье я поделюсь своим опытом оптимизации парсеров для нашего инструмента управления игровыми данными под названием Charon.

О оптимизации кода

Первым шагом в любой оптимизации кода является профилирование. Любые предположения о том, что нуждается в оптимизации без объективных данных профилирования, скорее всего, будут неверными. Многие, включая меня, попадали в ловушку, думая: «Я лучше знаю, как это будет выполняться». Даже с профилировщиком предположения о производительности конкретных частей вашей программы действительны только для вашего оборудования и версии ОС. Однако это лучше, чем вносить изменения вслепую.

Переход от парсинга char[] к byte[]

При написании парсеров для текстовых форматов данных или протоколов есть два способа обработки входных данных: обращаться к ним как к строкам или как к числам. JSON — это текстовый формат, поддерживающий строки в кодировке UTF-8, но все значимые для разметки символы находятся в диапазоне ASCII. Это означает, что его можно парсить без декодирования символов из byte в char, но всё еще надо детектить и пропускать многобайтовые символы. Предыдущая реализация токенизатора была такой:

  1. Чтение байтов из потока в буфер byte[].

  2. Декодирование этого буфера byte[] в другой буфер char[].

  3. Токенизация этого буфера char[].

  4. Интерпретация этих токенов.

Идея оптимизации заключалась в том, чтобы токенизировать буфер байтов напрямую и декодировать только строковые токены:

  1. Чтение байтов из потока в буфер byte[].

  2. Токенизация этого буфера byte[].

  3. Декодирование только строковых токенов.

  4. Интерпретация этих токенов.

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

Когда у блока кода миллионы вызовов: самые незначительные изменения влияют на производительность

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

тут в NextLexeme() крутится каждый байт из входных данных
тут в NextLexeme() крутится каждый байт из входных данных

Замена цепочек if на единичные обращения к массиву

Например, определение, является ли символ пробелом, можно выполнить с помощью цепочки if, но чем длиннее цепочка, тем больше операций требуется на каждый символ. В случае JSON все символы пробелов (их много) находятся в диапазоне ASCII (0-127), поэтому эту проверку можно выполнить с помощью единичного обращения к массиву. Другая оптимизация — замена такой цепочки if на switch, и если значения являются последовательными числами, они превращаются в таблицу переходов, что дешевле по исполнению, чем множество операторов if.

// OLD
if (charCode == SPACE_CHAR_CODE ||
     (charCode >= TAB_CHAR_CODE && charCode <= RETURN_CHAR_CODE) ||
     charCode == NO_BREAK_SPACE_CHAR_CODE ||
     charCode == IDENTIFIER_SEPARATOR_CHAR_CODE ||
     charCode == VALUE_SEPARATOR_CHAR_CODE ||
     charCode == END_ARRAY_CHAR_CODE ||
     charCode == END_OBJECT_CHAR_CODE ||
     charCode == BEGIN_OBJECT_CHAR_CODE ||
     charCode == BEGIN_ARRAY_CHAR_CODE) {
  // ...
}

// NEW
static bool[] LexemeTerminatorChars;

if (charCode < LexemeTerminatorChars.Length && 
    LexemeTerminatorChars[charCode]) {
  // ...
}

Развертывание структуры ArraySegment из поля в локальные переменные

Токенизатор не читает файл байт за байтом, а работает с буфером данных. В C# структура ArraySegment используется для фрагментов данных и изначально хранится в классе чтения, занимая 16 байт. Токенизатор обращается к ней много раз в цикле при парсинге буфера на токены. Поскольку компилятор не может предположить состояние этого поля в классе между обращениями, каждое обращение генерирует инструкции для чтения, проверки на null для ArraySegment.Array и проверки границ. Переместив массив и границы в метод и вне цикла, компилятор может оптимизировать эти проверки. Это простое изменение дает заметный прирост производительности на горячих блоках кода .

private ArraySegment<byte> rawJsonValue;

// OLD
bool NextToken()
{
  if (this.rawJsonValue.Count == 1)
  {
    var oneCharNotation = this.rawJsonValue[this.rawJsonValue.Offset];
    // ...
  }
  else if (this.rawJsonValue.Count == 4 && 
           SequenceEqual(this.rawJsonValue, JsonNotation.Null)) 
  {
    // ...
  }
}


// NEW
bool NextToken()
{
  var rawJsonArray = this.rawJsonValue.Array;
  var rawJsonOffset = this.rawJsonValue.Offset;
  var rawJsonLength = this.rawJsonValue.Count;

  if (rawJsonLength == 1)
  {
    var oneCharNotation = rawJsonArray[rawJsonOffset];
    // ...
  }
  else if (rawJsonLength == 4 && 
           SequenceEqual(rawJsonArray, rawJsonOffset, JsonNotation.Null)) 
  {
    // ...
  }
}

Использование класса Utf8Parser для преобразования byte[] в числа/DateTime/TimeSpan

.NET давно поддерживает парсинг дат, времени и чисел напрямую из byte[], минуя стадию декодирования байт в string, используя класс Utf8Parser. Использование этого класса уменьшает количество выделений памяти для string и улучшает производительность парсинга чисел из текста.

// OLD
double ReadJsonAsNumber()
{
  string tokenAsString = this.ReadJsonAsString(); // costly and wasteful
  return double.Parse(tokenAsString, CultureInfo.InvariantCulture);
}

// NEW
double ReadJsonAsNumber()
{
   if (Utf8Parser.TryParse(this.rawJsonValue.AsSpan(), out double value, out _))
   {
      return value;
   }
   else 
   {
      throw new FormatException(/* error message */);
   }
}

Развертывание сложной структуры ReaderNode в отдельные поля: от абстракции к конкретной реализации

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

Моя предыдущая реализация парсера имела элегантный интерфейс для текущего состояния парсера, где толстенькая структура ReaderNode хранила текущий токен и его значение. Значение токена также было абстрагировано для поддержки хранения чисел, строк и сложных объектов. Согласно профилировщику, эта гибкость имела значительную стоимость в производительности парсера. Оптимизация заключалась в упрощении всего до примитивных свойств/полей и отказе от абстракций.

// FROM
class JsonGameDataReader {
  public ReaderNode Node { get;}
}

public readonly struct ReaderNode
{
  private readonly IStrongBox value;

  public readonly ReaderToken Token;
  public readonly Type ValueType;

  public bool ValueAsBoolean => /* ... */
  /* ... */
}

// TO
class JsonGameDataReader {
  private ReaderToken token;
  private bool boolValue;
  private bool stringValue;
  /* ... other types */
 
  public ReaderToken Token => this.token;
  public bool ValueAsBoolean => this.boolValue;
  public bool ValueAsString => this.stringValue;
}

Использование кэширования строк для уменьшения выделения памяти

«Есть две сложные проблемы в компьютерной науке: инвалидация кэша, именование вещей и ошибки на единицу.» — Leon Bambrick

Еще один трюк из времен бородачей — кэширование результатов тяжелых алгоритмов. Наиболее затратной по памяти и производительности частью парсинга JSON является обработка строк. Часто результат этой обработки используется только один раз и затем отбрасывается. Я говорю о названиях полей, которые часто повторяются и используются только для связывания данных с объектами. Их выделения можно уменьшить. Я реализовал простой кэш, используя Dictionary<Int64, string>, где ключом является первые 8 байт строки, интерпретированные как 64-битное целое число. Этот ключ может приводить к коллизиям между строками вроде "A\0\0\0\0\0\0\0" и "A", но в моем случае это не было проблемой.

if (this.rawJsonValue.Count <= 8)
{
  var stringCacheKey = 0UL;

  // create key from rawJsonValue bytes
  var rawJsonArray = this.rawJsonValue.Array;
  var end = this.rawJsonValue.Offset + this.rawJsonValue.Count;
  for (var offset = this.rawJsonValue.Offset; offset < end; offset++)
  {
    var charCode = rawJsonArray[offset];
    stringCacheKey = stringCacheKey << 8 | charCode;
  }
  //

  if (this.stringPool.TryGetValue(stringCacheKey, out var stringValue))
  {
    return stringValue;
  }
  else
  {
    var chars = this.ReadJsonAsChars();
    return this.stringPool[stringCacheKey] = stringValue = new string(chars.Array ?? this.charBuffer, chars.Offset, chars.Count);
  }
}

Заключение

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

Буду рад услышать ваши мысли и опыт по подобным оптимизациям.

Код парсера после улучшений

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


  1. Tzimie
    12.07.2024 13:44
    +6

    В 90е никто в критических по производительности местах токены никуда не перекладывал, все старались делать за один проход конечным автоматом, символ за символом, без malloc


    1. DrZlodberg
      12.07.2024 13:44
      +1

      Писал как-то себе xml-sax парсер на сях (да, ещё один :)) Он при работе просто конечным автоматом размечал нулями конец строк в буфере и коллекционировал ссылки начала строк. Работало быстро. Но он был не утф. По тз там однобайтовая кодировка была.


  1. TerekhinSergey
    12.07.2024 13:44

    А расскажите пожалуйста, в чём преимущество перед стандартным парсером из System.Text.Json? Есть ли бенчмарки? Кажется, что даже десятки мегабайт, прочитанные один раз (речь ведь про настройки) не должны стать проблемой


    1. shai_hulud Автор
      12.07.2024 13:44
      +2

      А расскажите пожалуйста, в чём преимущество перед стандартным парсером из System.Text.Json?

      Преимущество в том, что такой парсер работает в любой версии Unity и как и .NET. Этот парсер часть генерируемого кода тулы для чтения игровых конфигов. Мне неизвестно, где незвестно где этот код будет запущен. Оно плавно деградирует (через #if) до самых плохих условий исполнения и работает везде. В то время как System.Text.Json хочет свои зависимости и правильный рантайм со Span и ReadOnlySequence.

      Есть ли бенчмарки?

      Только что сделал 10mb JSON на .NET Framework 4.7.1 (Core могут быть чуть другие результаты):

      | Method                         | Mean     | Error    | StdDev   | Ratio | RatioSD | Gen0      | Gen1     | Gen2     | Allocated | Alloc Ratio |
      |------------------------------- |---------:|---------:|---------:|------:|--------:|----------:|---------:|---------:|----------:|------------:|
      | JsonFormatter                  | 74.45 ms | 1.163 ms | 1.384 ms |  1.03 |    0.03 | 3714.2857 | 142.8571 |        - |  23.82 MB |        1.05 |
      | SystemTextJsonFormatter        | 72.40 ms | 0.607 ms | 0.538 ms |  1.00 |    0.00 | 3714.2857 |        - |        - |  22.64 MB |        1.00 |
      | JsonFormatterWithStringPooling | 65.76 ms | 0.743 ms | 0.695 ms |  0.91 |    0.01 | 2500.0000 | 750.0000 | 250.0000 |  14.98 MB |        0.66 |
      

      Кажется, что даже десятки мегабайт, прочитанные один раз (речь ведь про настройки) не должны стать проблемой

      Вот это только токенизация 10Mb JSON т.е. "Read() + reader.GetString()/GetInt()", а еще сколько занимает создание объектов и складывание в них. Представьте теперь вместо моего современного PC процессора мобильный процессор который троттлится от жары.


      1. pfffffffffffff
        12.07.2024 13:44
        +6

        А зачем вы тогда используете формат json, а не готовую структуру байт которую можно замаппить в самом приложении, и тут не будет никакого лишнего парсинга.

        Ну используйте свой json и храните его в гите, а при билде в приложение подсовывать готовое упакованное представление. И зачем тогда нужны все эти оптимизации?


        1. shai_hulud Автор
          12.07.2024 13:44

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

          Мои ответы:
          а) один источник правды - сам JSON/YAML/INI/CSV файл
          б) возможность по-быстрому подправить и перезапустить игру без экспортов в бинарный формат
          в) возможность моддерам без доп инструментов что-то добавить/убрать/затюнить


  1. RenatSh
    12.07.2024 13:44
    +3

    Вспомнился simdjson отсюда https://habr.com/ru/companies/quadcode/articles/660229/


    1. shai_hulud Автор
      12.07.2024 13:44
      +1

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


  1. kemsky
    12.07.2024 13:44
    +2

    Возникает вопрос, может быть стоит конвертировать данные в более удобный для машины бинарный формат?


    1. shai_hulud Автор
      12.07.2024 13:44

      Да, есть еще вариант хранить в формате MessagePack, и даже есть парсер который в 2.7х быстрее чем JSON. Но у бинарных форматов есть огромный минус, они плохо диффятся и мержатся в системах контроля версий. Даже без мержей, просто посмотреть что поменялось в бинарном файле невозможно.


      1. gev
        12.07.2024 13:44

        А если взять Твместо JSON s-expressions?
        Ребята в яндексе как раз подбное делали:
        https://youtu.be/H6Lb5L5A_2c?t=802


  1. shares-caisson
    12.07.2024 13:44

    simdjson уже упомянули, бинарные форматы упомянули, остаётся ещё yyjson упомянуть. Ну и конечно в статье про улучшение производительности очень нехватает результатов замеров до и после (и в процессе), понять получилось быстро или медленно (относительно лучших существующих решений) невозможно. О файлах какого размера речь хотя бы идёт? И о каком времени обработки? И что мешает взять существующие библиотеки в проект, где важна производительность?


    1. shai_hulud Автор
      12.07.2024 13:44

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

      В комментарии https://habr.com/ru/articles/828502/comments/#comment_27035122 есть ответы на все эти вопросы и бенчмарк.


  1. VanKrock
    12.07.2024 13:44
    +1

    Если речь о utf8 то есть смысл сравнить с https://github.com/neuecc/Utf8Json там много оптимизаций именно под utf8, к сожалению проект в архиве, но сами подходы там интересные


  1. Fardeadok
    12.07.2024 13:44

    Это смешно без тестов скорости. И что значит парсинг? - в структуру? В интерфейс? В переменные? Сколько строк в секунду этот парсер обрабатывает?


  1. mynameco
    12.07.2024 13:44
    +2

    Как только в unity завезли парсинг напрямую из Span, я переписал сериализаторы практически без выделений.

    Основа это два класса, где внутри данные на основе чанков. Первый это структуры нодов, второй это куски байт данных.

    При парстнге строится дерево в глубину. Ссылки на данные по индексу во втором классе.

    Если данные это строки, то да, создается string. А если нет, то парсится, без лишних преобразований.


    1. shai_hulud Автор
      12.07.2024 13:44

      Первый это структуры нодов, второй это куски байт данных.

      Второй ReadOnlySequence<T> который набор сегментов с ссылкой на Next. А можете показать код первого для изучения, если это не секрет.