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 вызывает вашу функцию для каждого события и распределяет работу по заданному числу потоков.
Каждый поток хранит собственные счётчики в 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.