Python разбирает JSON на 66 ГБ/с: GIL больше не мешает

Python разбирает JSON на 66 ГБ/с: GIL больше не мешает

Дэниел Лемир, автор simdjson и человек, который много лет выжимает максимум из разбора JSON, опубликовал свежий бенчмарк: 7,6 ГБ JSON обрабатываются в одном процессе Python примерно за 0,12 секунды. Это около 66 ГБ/с. Ещё пару лет назад такую цифру рядом со словом Python приняли бы за опечатку.

Что изменилось в Python 3.14

Параллельность в CPython долго упиралась в глобальную блокировку интерпретатора (GIL): в каждый момент времени Python-код выполнял только один поток. Для задач, которые грузят процессор, приходилось поднимать отдельные процессы через multiprocessing и платить за пересылку данных между ними. В Python 3.14 появилась официальная сборка без GIL (python3.14t), где несколько потоков выполняют Python-код одновременно. Бенчмарк Лемира проверяет, сколько из этого можно выжать на реальной задаче.

Задача и данные

Данные взяты из GH Archive. Проект собирает все публичные события GitHub (пуши, issues, pull requests, звёзды) в формате NDJSON: одно событие на строку, один файл на каждый час. Лемир взял восемь часов 13 марта 2024 года, это 7,6 ГБ распакованного JSON и 2,2 миллиона событий. Средний объект весит около 3,4 КБ, а для задачи из него нужно всего несколько полей. Нужно посчитать события по типам и для каждого открытого pull request определить язык репозитория.

Результат за эти восемь часов: открыто 65 269 pull request. Больше всего пришлось на TypeScript (11 565), дальше идут JavaScript (7 778), Python (7 057) и Java (4 668). Ещё 5 857 PR ушли в репозитории, где язык не указан.

Код на fastsimdjson

Для разбора используется fastsimdjson, новая Python-обвязка для simdjson. Функция map_many вызывает вашу функцию для каждого события и распределяет работу по заданному числу потоков.

import collections, threading import fastsimdjson local = threading.local() parts = [] def one_event(e): acc = getattr(local, "acc", None) if acc is None: # первое событие, которое видит этот поток acc = local.acc = (collections.Counter(), collections.Counter()) parts.append(acc) t = e["type"] acc[0][t] += 1 if t == "PullRequestEvent": p = e["payload"] if p["action"] == "opened": acc[1][p["pull_request"]["base"]["repo"]["language"]] += 1 data = open("events.json", "rb").read() fastsimdjson.map_many(one_event, data, format="lines", threads=64, lazy=True) types, languages = collections.Counter(), collections.Counter() for t, lang in parts: types.update(t) languages.update(lang)

Каждый поток хранит собственные счётчики в threading.local. Первое событие, попавшее в поток, создаёт пару Counter и добавляет её в общий список parts, дальше поток обновляет только свою пару. Общих изменяемых данных нет, поэтому потоки не ждут друг друга. Когда map_many завершается, главный поток проходит по parts и складывает результаты через Counter.update. Типов событий несколько десятков, языков около сотни, так что слияние занимает ничтожную долю времени на фоне парсинга.

Самый важный параметр здесь lazy=True. С ним функция получает ленивое представление события вместо словаря: документ разобран, но Python-объекты создаются только для тех значений, к которым вы обращаетесь. Первым такой подход реализовал Тайлер Кеннеди в другой обвязке, pysimdjson.

С чем сравнивали

В соперниках стандартный модуль json, orjson и msgspec. У msgspec есть сильный козырь: он декодирует JSON сразу в типизированные структуры, где объявлены только нужные поля, а всё остальное пропускает. По смыслу это близко к ленивым представлениям, только схему нужно описать заранее. Лемир дал msgspec это преимущество. Во всех вариантах итоговые счётчики сверялись и совпали.

Стенд: сервер с двумя Intel Xeon Gold 6548N (всего 64 ядра), Python 3.14.6, fastsimdjson 0.6.0, orjson 3.13.0, msgspec 0.22.0. Данные распаковываются в память до запуска таймера. В зачёт идёт лучший из семи прогонов, медленные варианты запускались по одному разу.

Одно ядро

Стандартной библиотеке на восемь часов событий нужно 42 секунды. fastsimdjson с ленивыми представлениями укладывается в 3,6 секунды. Показательно, что медленными оказываются все варианты, которые материализуют документ целиком: json, orjson и fastsimdjson без lazy. Основное время у них уходит на создание Python-объектов, которые сразу же выбрасываются.

64 ядра в одном процессе

На free-threaded Python для json и msgspec использовался пул потоков, для fastsimdjson вызов map_many(…, threads=64). Все потоки читают общий входной буфер.

fastsimdjson с ленивыми представлениями выдаёт 65,7 ГБ/с, без них 29,8 ГБ/с. msgspec с типизированными Struct на 64 потоках показывает 24,6 ГБ/с, стандартный json 3,17 ГБ/с. Все 7,6 ГБ проходят примерно за 0,12 секунды, в 31 раз быстрее, чем на одном ядре.

Что взять себе

Если вы обрабатываете большие NDJSON-логи, выгрузки событий или датасеты, первое ускорение дают ленивые представления или схема msgspec: Python-объекты создаются только для полей, которые реально нужны. Это ускоряет работу в разы ещё до всякого параллелизма. Сборка без GIL добавляет масштабирование по ядрам внутри одного процесса, без multiprocessing и копирования данных между процессами.

fastsimdjson 0.6.0 уже доступен на PyPI, код бенчмарка лежит в репозитории Лемира на GitHub.

Полный разбор с кодом и ссылками на исходники: uproger.com.