Разбор JSON в simdjson начинается с поиска структурных символов — скобок, запятых, двоеточий. Именно по ним строится карта документа, и именно эта стадия определяет, насколько быстро парсер движется по входным данным. На ARM классификация символов долгое время опиралась на табличный поиск NEON; в SVE2 появилась инструкция match, которая умеет находить принадлежность байта небольшому множеству за один проход. Разберём, как устроена классификация в обеих версиях, как они сосуществуют в одном коде и что показали замеры на Graviton 4 и Graviton 5.
Что делает stage1: поиск структурных символов
Первый этап разбора (stage1) отмечает в документе всё, что относится к структуре: строки, пробелы, скаляры и структурные символы. Структурные элементы в сканере обозначаются как operatorsструктурные символы JSON: скобки, запятые и двоеточия. Метод potential_structural_start объединяет их с потенциальными скалярными началами.
Ключевая идея — работать с битовыми масками. Каждый класс символов представлен 64-битной маской, где установленный бит означает позицию символа этого класса. Дальше маски комбинируются побитовыми операциями.
potential_structural_start() возвращает маску позиций, которые могут быть началом структурного элемента или скалярапростое значение JSON: число, строка, true, false или null. potential_scalar_start() отмечает начало новой скалярной последовательности: она берёт маску скаляров и исключает те позиции, которые следуют за уже идущим скаляром. structural_start() уточняет маску, убирая всё, что находится внутри строки:
potential_structural_start() & ~_string.string_tail()whitespace() возвращает пробельные символы вне строк. non_quote_inside_string(mask) оставляет только позиции внутри строки, non_quote_outside_string(mask) — только вне. Эти два метода различают позиции внутри и вне строки для всех символов, кроме кавычек.
Как классифицируются символы в NEON
В NEON-версии структурные байты определяются через таблицу op_table, которая сопоставляет значениям байтов нужные символы. Таблица задаётся как вектор из 16 байт:
const uint8x16_t op_table = simd8<uint8_t>( 0xff, 0, ',', ':', 0, '[', ']', '{', '}', 0, 0, 0, 0, 0, 0, 0 );По индексам 2, 3, 5, 6, 7, 8 стоят символы-операторы ,, :, [, ], {, }, а 0xff служит заведомо невозможным в JSON значением-заполнителем. Затем выполняется табличный поиск vqtbl1q_u8 по индексу, вычисленному из входного байта, а результат сравнивается с исходным байтом через vceqq_u8. Совпадение даёт маску «этот байт — оператор».
Формально: 4-битный индекс отображается в байт-признак, и если признак равен исходному байту, символ структурный. Такой приём позволяет обойтись одной таблицей на все шесть символов.
Инструкция match в SVE2
Инструкция match в SVE2 принимает вектор байтов и второй вектор, играющий роль небольшого множества, и выдаёт предикатбитовая маска, где установленный бит означает выполнение условия в данной позиции с установленным битом в каждой позиции, где входной байт принадлежит этому множеству. Поскольку она работает в пределах 128-битных сегментов, множество ограничено 16 байтами — этого достаточно для шести структурных символов JSON.
В функции match_operators_sve2 таблица operators из 16 байт содержит нужные символы и заполнитель 0xff как невозможное в JSON значение:
const uint8x16_t operators = { 0xff, ',', ':', '[', ']', '{', '}', 0xff, ',', ':', '[', ']', '{', '}', ',', ':' };Маска pg покрывает первые 16 значений: const svbool_t pg = svptrue_pat_b8(SV_VL16);. Входной NEON-вектор и таблица переносятся в SVE через svset_neonq_u8, после чего вызывается svmatch_u8(pg, data, table), возвращающая svbool_t. Именно поэтому классификация 16 ASCII-байт выполняется, возможно, одной инструкцией.
Мост между NEON и SVE
svset_neonq_u8 — часть «NEON-SVE bridge», позволяющего смешивать NEON и SVE. Он берёт NEON-значение типа uint8x16_t (16 восьмибитных целых) и помещает его в SVE-тип svuint8_t. По соглашению первые 16 байт SVE-типов общие с NEON, поэтому вызов может скомпилироваться в ничто.
В match_operators_sve2 этот приём применяется дважды: сначала к входным 16 ASCII-байтам, затем к таблице символов-операторов, после чего вызывается svmatch_u8(pg, data, table).
В обратную сторону работает svget_neonq_u8, тоже часть моста: в operator_predicates_to_bytes он извлекает NEON-значения из результатов svsel_u8, чтобы затем сложить их NEON-инструкциями vpaddq_u8 и получить 64-битную маску.
Сосуществование в одном парсере устроено так, что SVE2-код применяется только при сборке с -mcpu=native или эквивалентом, а по умолчанию везде используется NEON. SVE2 требуется потому, что match входит в SVE2, а не в исходный SVE, и его нет у процессоров Apple и старых Graviton.
От предиката к 64-битной маске
Предикат svbool_t — это ещё не та маска, с которой работает остальной код. Её нужно свернуть в 64-битное число. Путь такой: каждый байт (0x00 или 0xff) сначала AND-ится с весами битов, затем соседние байты трижды суммируются через addp.
addp — это инструкция попарного сложения соседних элементов вектора: она складывает элементы парами и укладывает результаты в младшую половину вектора. Одного такого сложения достаточно, чтобы из 16 байтов, каждый из которых несёт один бит будущей маски, получить 8 байтов с двухбитными суммами; после второго сложения остаётся 4 байта, после третьего — 2 байта, то есть все 16 битов оказываются упакованы в одно 16-битное значение. Трёх раз достаточно потому, что 16 = 2³: каждое сложение вдвое уменьшает число элементов, и после трёх шагов от 16 байтов остаётся один результат.
В коде operator_predicates_to_bytes создаётся вектор весов bit_mask = {0x01, 0x02, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80, ...}, и для каждого предиката p0..p3 через svsel_u8 выбираются веса там, где предикат установлен, иначе ноль. Затем четыре 16-байтовых вектора b0..b3 сворачиваются: vpaddq_u8(vpaddq_u8(b0, b1), vpaddq_u8(b2, b3)) даёт промежуточную сумму, а финальный vpaddq_u8(sum, sum) сворачивает её до одного 64-битного значения, извлекаемого vgetq_lane_u64.
В simdjson аналогичный приём в to_bitmask: байты AND-ятся с bit_mask, затем три раза применяется vpaddq_u8, и результат читается как 16-битное значение через vgetq_lane_u16. Порядок операций неизменен: сначала маскирование весами (AND), затем последовательные свёртки соседних байтов, пока не останется одно значение.
Как строки влияют на классификацию
Классификация структурных символов не может выполняться в отрыве от разбора строк: запятая или скобка внутри строкового литерала — это не структура. json_string_scannerкомпонент, который отслеживает состояние внутри строки отслеживает состояние внутри строки через три битовые маски: escaped (символы, следующие за escape-символом), quote (настоящие кавычки, не экранированные) и string_content (символы внутри строки, не включая кавычки).
В next() сначала вычисляется backslash = in.eq('\\'), затем escaped = escape_scanner.next(backslash).escaped, а quote = in.eq('"') & ~escaped — кавычка считается настоящей только если она не экранирована. Состояние внутри строки вычисляется как in_string = prefix_xor(quote) ^ prev_in_string, где prefix_xorоперация, которая для каждой позиции битовой маски считает чётность числа установленных битов до неё включительно, то есть переключает состояние на каждой кавычке, а prev_in_string — флаг, были ли мы в строке на предыдущей итерации; в конце prev_in_string обновляется сдвигом in_string вправо на 63 бита.
Это необходимо до классификации структурных символов, потому что structural_start() маскирует потенциальные структурные символы через & ~_string.string_tail(), чтобы исключить всё, что находится внутри строки. Без этого структурные символы внутри строк были бы ошибочно приняты за настоящие структурные элементы JSON.
Экранированные кавычки и обратные слэши
Экранированные кавычки и обратные слэши обрабатываются через маски битов: backslash_and_quote::copy_and_find возвращает bs_bits (позиции обратных слэшей) и quote_bits (позиции кавычек), а has_quote_first() проверяет, идёт ли кавычка раньше обратного слэша, через ((bs_bits - 1) & quote_bits) != 0.
escaping::escape_index() возвращает trailing_zeroes(escape_bits) / 4, что даёт индекс первого экранированного символа, а has_escape() проверяет escape_bits != 0.
Это критично для классификации на match, потому что json_character_block::classify ищет структурные символы и скаляры параллельно со строками. Без исключения экранированных кавычек quote внутри строки ошибочно завершил бы строку, а in_string через prefix_xor(quote) переключился бы неверно. В результате potential_structural_start() и potential_scalar_start() пометили бы символы внутри строки как структурные, что привело бы к неверному разбору.
Как маски превращаются в индексы
json_scanner::next() получает от string_scanner.next(in) блок строк, а от json_character_block::classify(in) — классификацию символов (пробелы, структурные символы, скаляры). Затем он вычисляет маску скаляров вне кавычек как characters.scalar() & ~strings.quote() и через follows(nonquote_scalar, prev_scalar) строит follows_nonquote_scalar — маску символов, непосредственно следующих за некавычечным скаляром. Эти три части упаковываются в json_block, у которого structural_start() возвращает маску начал структурных символов с исключением содержимого строк.
json_scanner::finish() просто делегирует проверку незакрытой строки в string_scanner.finish().
Далее bit_indexer::write(uint32_t idx, uint64_t bits) берёт битовую маску, при bits == 0 сразу возвращается, иначе считает cnt = count_ones(bits) и для каждого установленного бита вызывает write_index, который записывает в tail[i] значение idx + trailing_zeroes(bits) и сбрасывает младший установленный бит через clear_lowest_bit(bits). После раскладки tail сдвигается на количество записанных индексов.
Точка расширения CUSTOM_BIT_INDEXER
SVE2-классификация встраивается через точку расширения CUSTOM_BIT_INDEXER: если ядро определяет SIMDJSON_GENERIC_JSON_STRUCTURAL_INDEXER_CUSTOM_BIT_INDEXER, то generic-код не компилирует собственную реализацию write, а ожидает её от реализации. В generic-версии write принимает idx и битовую маску bits, при bits == 0 сразу возвращается, иначе считает cnt = count_ones(bits) и раскладывает индексы: write_indexзаписывает в tail позицию очередного установленного бита и сбрасывает его, чтобы перейти к следующему и write_indexes_steppedзаписывает сразу несколько индексов за один шаг, продвигаясь по маске на заданное число битов — в отличие от write_index, который обрабатывает по одному биту за вызов, write_indexes_stepped обрабатывает их группой. После раскладки tail сдвигается на cnt.
Классификация символов выполняется в json_scanner::next, где json_character_block::classify(in) определяет пробелы и структурные символы, а string_scanner.next(in) — строки. SVE2-функция классифицирует 16 ASCII-байт, возможно, одной инструкцией match, используя svset_neonq_u8 для связи NEON и SVE и svsel для материализации предиката svbool_t в байты.
После использования макрос CUSTOM_BIT_INDEXER сбрасывается через #undef, чтобы другие реализации могли задать его при необходимости.
Обработка хвоста и неполных блоков
SVE2-специфичной обработки неполных блоков и хвоста нет: buf_block_reader::get_remainder, trim_partial_utf8 и memset описаны только в общем (generic) коде, который используется одинаково.
get_remainder заполняет весь буфер STEP_SIZE байтом 0x20, затем копирует остаток, а при len == idx возвращает 0 без копирования. trim_partial_utf8 вызывается только в потоковом режиме (is_streaming(partial)) и отсекает неполный последний UTF-8 символ, возвращая UTF8_ERROR, если после отсечения длина стала нулевой.
Усечённая длина len передаётся в buf_block_reader и в finish, где по ней выставляются sentinel-значениязначения-маркеры, записываемые в конец массива структурных индексов, чтобы обозначить границу обработанных данных structural_indexes[n_structural_indexes] = len и следующие два, а также вычисляется n_structural_indexes из indexer.tail. Сам битмаск структурных символов формируется как potential_structural_start() & ~_string.string_tail(), то есть незавершённые байты в конце окна в этот битмаск не попадают, поскольку они исключены из обрабатываемого диапазона.
Граничные случаи: пустой результат индексации
В json_structural_indexer есть проверка «a valid JSON file cannot have zero structural indexes». Она нужна потому, что корректный JSON-документ обязан содержать хотя бы один структурный индекс: если после индексации счётчик parser.n_structural_indexes равен нулю, значит ничего структурного не найдено, и функция возвращает EMPTY.
В режиме streaming_partial при наличии незакрытой строки последний структурный индекс (кавычка) отбрасывается уменьшением счётчика, и если после этого индексов не осталось, возвращается CAPACITY. В режиме streaming_final при незакрытой строке счётчик тоже уменьшается, а затем присваивается результат find_next_document_index; если он равен нулю, возвращается EMPTY, поскольку незакрытая строка в самом конце потока допускается.
В режимах json_sequence_partial и comma_delimited_partial при нулевом результате фильтрации пустая партия всё равно должна продвинуть next_batch_start, иначе document_stream будет бесконечно переразбирать те же байты. Поэтому при next_batch_start == 0 возвращается CAPACITY, иначе счётчик обнуляется, в structural_indexes[0] записывается next_batch_start и возвращается EMPTY. В режимах json_sequence_final и comma_delimited_final при нулевом счётчике возвращается EMPTY.
Почему таблица с 0xff, а не прямое сравнение
Таблица operators заполнена символами-операторами, а в качестве заполнителя используется 0xff, потому что это невозможное значение байта внутри JSON-документа. Такая таблица передаётся вторым аргументом в svmatch_u8(pg, data, table) — инструкцию SVE2 match, которая выдаёт предикат с битом на каждой позиции, где входной байт принадлежит множеству.
Поскольку match работает в пределах 128-битных сегментов, набор ограничен 16 байтами, и этого достаточно для шести структурных символов. Функция match_operators_sve2 классифицирует 16 ASCII-байт, возможно, всего одной инструкцией.
Ограничения подхода и переносимость
Функция match классифицирует 16 ASCII-байт, возможно, одной инструкцией. Ограничение: код требует SVE2, которого нет у процессоров Apple и старых Graviton, поэтому simdjson откатывается на NEON, когда SVE2 недоступен на этапе компиляции. Дополнительное ограничение — код компилируется только при сборке с -mcpu=native или эквивалентом, а сборка по умолчанию везде получает NEON-код.
Переносимость обеспечивается generic-реализациями: json_scanner в generic/stage1/json_scanner.h определяет next(const simd::simd8x64<uint8_t>& in) и finish(), а json_block хранит _string, _characters и _follows_potential_nonquote_scalar, вычисляя structural_start(). Эти generic-структуры работают с абстрактным simd8x64<uint8_t>, а конкретные операции вроде to_bitmask(), to_bitmask64(), compress() реализуются в simd.h через NEON-интринсики (vgetq_lane_u16, vshrn_n_u16, vqtbl1q_u8), что и позволяет подменять реализацию под архитектуру.
Почему SVE2-путь — отдельная специализация
SVE2-путь реализован отдельно, потому что инструкция match, на которой он основан, входит в SVE2, а не в исходный SVE, и потому недоступна на части процессоров ARM. Среди AWS Graviton процессоров Graviton 3 (Neoverse V1) имеет SVE, но не SVE2, поэтому он не может выполнять этот код. Кроме того, код компилируется только при сборке с -mcpu=native или эквивалентом, а сборка по умолчанию везде получает NEON-код. Поэтому simdjson откатывается на NEON, когда SVE2 недоступен во время компиляции.
Выигрыш при этом скромный: вторая стадия парсера не затрагивается, так что общий выигрыш составляет 2–4% на Graviton 4 и 1–2% на Graviton 5.
Как сравнивали и что получилось
Стенд и методология. Использовался бенчмарк parse, входящий в состав simdjson, на стандартном корпусе из 22 JSON-файлов общим объёмом около 24 МБ. Каждый файл парсился 300 раз с сохранением лучшего времени. Каждый бинарник запускался трижды, чередуясь с парным вариантом, с привязкой к одному ядру, и бралось лучшее время; разброс между запусками менее 1%. Сборка выполнялась GCC 15 и LLVM clang 21 на Ubuntu 26.04 с флагом -mcpu=native. Стенды: Graviton 4 (Neoverse V2) на инстансе c8g.2xlarge и Graviton 5 (Neoverse V3) на инстансе c9g.2xlarge, оба со 128-битными SVE-регистрами.
Результаты. Диапазон 3%–9% относится к ускорению стадии индексации (stage 1) в реальном парсере на Graviton 4 и Graviton 5. В абсолютных числах стадия индексации растёт так:
| Стенд | Компилятор | Было | Стало |
|---|---|---|---|
| Graviton 4 | clang | 4.8 ГБ/с | 5.3 ГБ/с |
| Graviton 4 | GCC | 5.5 ГБ/с | 5.8 ГБ/с |
| Graviton 5 | clang | 6.3 ГБ/с | 6.6 ГБ/с |
| Graviton 5 | GCC | 7.1 ГБ/с | 7.3 ГБ/с |
По файлам наименьший выигрыш дают canada, mesh, marine_ik — в основном числа, где индексация и так дешёвая. Наибольший — gsoc-2018, random, github_events, где много структуры.
Оговорки. Выигрыш относится к стадии индексации, а вторая стадия парсера не тронута, поэтому выигрыш на всём парсинге меньше: 2–4% на Graviton 4 и 1–2% на Graviton 5. Ни один файл не замедляется, кроме canada и mesh на Graviton 5 с GCC (на 2–3%, на грани измеримости). Цифры получены на конкретном корпусе и конкретных стендах; они не показывают поведения на других наборах данных и не описывают влияние на вторую стадию разбора.
Что из этого следует на практике
- Классификация структурных символов — это работа с битовыми масками, а не посимвольный разбор. Любая оптимизация здесь сводится к тому, чтобы быстрее получить маску и быстрее свернуть её в индексы.
- NEON-путь использует табличный поиск
vqtbl1q_u8с индексом(байт+3)>>4и сравнением результата с исходным байтом. SVE2-путь заменяет это одной инструкциейsvmatch_u8по таблице из 16 байт. - Заполнитель
0xffв таблицах — не случайность: это невозможное в JSON значение, поэтому он не может дать ложное совпадение. - Строки нужно разбирать до классификации структурных символов, иначе запятые и скобки внутри литералов попадут в маску структуры. Экранированные кавычки исключаются через
quote = in.eq('"') & ~escaped. - SVE2-код включается только при сборке с
-mcpu=nativeили эквивалентом; обычная сборка везде получает NEON. Выбор SVE2 во время выполнения ещё не реализован. - Выигрыш ограничен первой стадией: 3–9% на индексации превращаются в 2–4% на Graviton 4 и 1–2% на Graviton 5 на всём разборе.
- Наибольшую пользу получают документы с большим количеством структуры; на данных, состоящих в основном из чисел, индексация и так дешёвая, и ускорять там почти нечего.
Где смотреть в коде
- json_string_scanner.h: json_string_block
- json_structural_indexer.h: trim_partial_utf8
- json_scanner.h: json_block
- json_scanner.h: potential_structural_start
- json_scanner.h: next
- json_structural_indexer.h: write
- simd.h: base_u8
- stringparsing_defs.h: has_quote_first