Введение
Во время разработки игр объем данных, обрабатываемых игрой, увеличивается. Художники создают новые ресурсы, программисты пишут код, а геймдизайнеры добавляют больше конфигураций и настроек в игру. Когда размер этих конфигураций превышает десятки мегабайт, производительность даже самых тривиальных компонентов, таких как JSON-парсер конфигураций, становится критичной. В этой статье я поделюсь своим опытом оптимизации парсеров для нашего инструмента управления игровыми данными под названием Charon.
О оптимизации кода
Первым шагом в любой оптимизации кода является профилирование. Любые предположения о том, что нуждается в оптимизации без объективных данных профилирования, скорее всего, будут неверными. Многие, включая меня, попадали в ловушку, думая: «Я лучше знаю, как это будет выполняться». Даже с профилировщиком предположения о производительности конкретных частей вашей программы действительны только для вашего оборудования и версии ОС. Однако это лучше, чем вносить изменения вслепую.
Переход от парсинга char[] к byte[]
При написании парсеров для текстовых форматов данных или протоколов есть два способа обработки входных данных: обращаться к ним как к строкам или как к числам. JSON — это текстовый формат, поддерживающий строки в кодировке UTF-8, но все значимые для разметки символы находятся в диапазоне ASCII. Это означает, что его можно парсить без декодирования символов из byte
в char
, но всё еще надо детектить и пропускать многобайтовые символы. Предыдущая реализация токенизатора была такой:
Чтение байтов из потока в буфер
byte[]
.Декодирование этого буфера
byte[]
в другой буферchar[]
.Токенизация этого буфера
char[]
.Интерпретация этих токенов.
Идея оптимизации заключалась в том, чтобы токенизировать буфер байтов напрямую и декодировать только строковые токены:
Чтение байтов из потока в буфер
byte[]
.Токенизация этого буфера
byte[]
.Декодирование только строковых токенов.
Интерпретация этих токенов.
Это потребовало значительного изменения токенизатора и само по себе обеспечило минимальное улучшение производительности. Однако этот подход позволил реализовать несколько других оптимизаций, таких как кэширование коротких строк и использование парсеров чисел сразу из byte[]
, о которых будет рассказано ниже.
Когда у блока кода миллионы вызовов: самые незначительные изменения влияют на производительность
Типичный парсер данных состоит из токенизатора и лексера. Токенизатор обрабатывает каждый байт/символ в потоке данных и разделяет поток данных на токены, в то время как лексер придает этим токенам контекст (этот токен — число, этот токен — строка и т. д.). Код в цикле токенизации вызывается наиболее часто, и даже небольшие оптимизации могут значительно повысить производительность.
Замена цепочек 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)
TerekhinSergey
12.07.2024 13:44А расскажите пожалуйста, в чём преимущество перед стандартным парсером из System.Text.Json? Есть ли бенчмарки? Кажется, что даже десятки мегабайт, прочитанные один раз (речь ведь про настройки) не должны стать проблемой
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 процессора мобильный процессор который троттлится от жары.
pfffffffffffff
12.07.2024 13:44+6А зачем вы тогда используете формат json, а не готовую структуру байт которую можно замаппить в самом приложении, и тут не будет никакого лишнего парсинга.
Ну используйте свой json и храните его в гите, а при билде в приложение подсовывать готовое упакованное представление. И зачем тогда нужны все эти оптимизации?
shai_hulud Автор
12.07.2024 13:44У каждого разработчика игр свой ответ на этот вопрос. За очень редким исключением большинство выбирают текстовые форматы для хранения игровых конфигов.
Мои ответы:
а) один источник правды - сам JSON/YAML/INI/CSV файл
б) возможность по-быстрому подправить и перезапустить игру без экспортов в бинарный формат
в) возможность моддерам без доп инструментов что-то добавить/убрать/затюнить
RenatSh
12.07.2024 13:44+3Вспомнился simdjson отсюда https://habr.com/ru/companies/quadcode/articles/660229/
shai_hulud Автор
12.07.2024 13:44+1Видел тот код. Автор simdjson мыслил другими категориями и понятиями пока недостижимыми для меня. Там весь токенизатор это две инструкции лукапа по таблицам, и никаких стейт машин.
kemsky
12.07.2024 13:44+2Возникает вопрос, может быть стоит конвертировать данные в более удобный для машины бинарный формат?
shai_hulud Автор
12.07.2024 13:44Да, есть еще вариант хранить в формате MessagePack, и даже есть парсер который в 2.7х быстрее чем JSON. Но у бинарных форматов есть огромный минус, они плохо диффятся и мержатся в системах контроля версий. Даже без мержей, просто посмотреть что поменялось в бинарном файле невозможно.
gev
12.07.2024 13:44А если взять Твместо JSON s-expressions?
Ребята в яндексе как раз подбное делали:
https://youtu.be/H6Lb5L5A_2c?t=802
shares-caisson
12.07.2024 13:44simdjson уже упомянули, бинарные форматы упомянули, остаётся ещё yyjson упомянуть. Ну и конечно в статье про улучшение производительности очень нехватает результатов замеров до и после (и в процессе), понять получилось быстро или медленно (относительно лучших существующих решений) невозможно. О файлах какого размера речь хотя бы идёт? И о каком времени обработки? И что мешает взять существующие библиотеки в проект, где важна производительность?
shai_hulud Автор
12.07.2024 13:44Ну и конечно в статье про улучшение производительности очень нехватает результатов замеров до и после (и в процессе), понять получилось быстро или медленно (относительно лучших существующих решений) невозможно. О файлах какого размера речь хотя бы идёт? И о каком времени обработки? И что мешает взять существующие библиотеки в проект, где важна производительность?
В комментарии https://habr.com/ru/articles/828502/comments/#comment_27035122 есть ответы на все эти вопросы и бенчмарк.
VanKrock
12.07.2024 13:44+1Если речь о utf8 то есть смысл сравнить с https://github.com/neuecc/Utf8Json там много оптимизаций именно под utf8, к сожалению проект в архиве, но сами подходы там интересные
Fardeadok
12.07.2024 13:44Это смешно без тестов скорости. И что значит парсинг? - в структуру? В интерфейс? В переменные? Сколько строк в секунду этот парсер обрабатывает?
mynameco
12.07.2024 13:44+2Как только в unity завезли парсинг напрямую из Span, я переписал сериализаторы практически без выделений.
Основа это два класса, где внутри данные на основе чанков. Первый это структуры нодов, второй это куски байт данных.
При парстнге строится дерево в глубину. Ссылки на данные по индексу во втором классе.
Если данные это строки, то да, создается string. А если нет, то парсится, без лишних преобразований.
shai_hulud Автор
12.07.2024 13:44Первый это структуры нодов, второй это куски байт данных.
Второй ReadOnlySequence<T> который набор сегментов с ссылкой на Next. А можете показать код первого для изучения, если это не секрет.
Tzimie
В 90е никто в критических по производительности местах токены никуда не перекладывал, все старались делать за один проход конечным автоматом, символ за символом, без malloc
DrZlodberg
Писал как-то себе xml-sax парсер на сях (да, ещё один :)) Он при работе просто конечным автоматом размечал нулями конец строк в буфере и коллекционировал ссылки начала строк. Работало быстро. Но он был не утф. По тз там однобайтовая кодировка была.