Мы получили пятикратное ускорение RocksDB на Python без GIL. А потом запустили HTTP-сервис

Что происходит, когда впечатляющий результат микробенчмарка сталкивается с маршрутизацией, потоками, IPC и реальной обработкой запросов.

С чего всё началось

Мы работали над экспериментальным key-value-сервисом на RocksDB. Одна из его задач — быстро обслуживать чтения по ключу, в том числе пакетные, поверх нескольких физических разделов данных. Доступ к RocksDB осуществлялся из Python через Rust/PyO3-библиотеку RocksDict.

К этому моменту у нас существовали две реализации чтения. В in-process варианте HTTP-сервер держал открытые RocksDB-объекты внутри собственного процесса, а блокирующие операции выполнялись через пул потоков. В multiprocess варианте HTTP-router передавал запросы отдельным reader-процессам, используя межпроцессный обмен. Каждый процесс обслуживал свой физический shard.

Затем появилась возможность попробовать CPython 3.14t, то есть free-threaded сборку Python. Мы подготовили совместимую сборку RocksDict, а заодно экспериментальную версию с обновлением нативной RocksDB с 9.8.4 до 11.8.1.

Ожидания были вполне понятные. Если несколько Python-потоков смогут одновременно выполнять код без ограничений GIL, массовое чтение должно стать быстрее. А обновление RocksDB потенциально даст новые возможности для I/O. Но насколько изменится скорость сервиса, а не одного вызова get()?

Решили проверять последовательно — от микробенчмарка до настоящего HTTP-пути.

Четыре варианта вместо одного сравнения

Сравнивать только Python 3.12 и 3.14t было бы методологической ошибкой: одновременно меняются версия интерпретатора и режим GIL. Поэтому сделали четыре конфигурации.

Мы получили пятикратное ускорение RocksDB на Python без GIL. А потом запустили HTTP-сервис

В паре B → C интерпретатор и wheel не меняются — можно изолировать эффект отключения GIL. В паре C → D неизменен Python, но меняется движок RocksDB. Полный переход от A к C тоже важен, только это уже сумма нескольких изменений.

Все варианты тестировались в отдельных средах. Для каждой нативной версии создавалась собственная новая RocksDB: несовместимые форматы не пытались открывать друг другом.

Эксперимент первый. Один RocksDict, восемь Python-потоков

Начали с максимально простого сценария. Небольшая база: 25 000 ключей, значение 256 байт. В одном процессе создаётся RocksDict, затем несколько потоков параллельно выполняют случайные GET. Каждый ответ сверяется с эталоном.

Проверили 1, 2, 4 и 8 потоков. Для каждого варианта сделали по два запуска в прямом и обратном порядке. Никаких холодных чтений SSD здесь нет: около 6,1 МиБ полезных значений отлично помещаются в RAM.

Получилось до 4,3 миллиона GET в секунду

Медианы двух повторов; единица — выполненные GET/с.
Медианы двух повторов; единица — выполненные GET/с.

На одном потоке различия между Python 3.12 и 3.14t с прежней RocksDB почти нет. С включённым GIL добавление потоков тоже почти не увеличивает суммарную скорость.

Зато отключение GIL на том же Python 3.14t дало 1,99× на двух потоках, 3,42× на четырёх и 5,04× на восьми. Если сравнивать восемь потоков с одним внутри самой free-threaded конфигурации, масштабирование составляет 4,56× — примерно 57% идеального восьмикратного прироста.

То есть портирование RocksDict под no-GIL оказалось не формальностью: параллельные вызовы реально начали выполняться быстрее.

Но в этом же тесте обнаружился первый неприятный сюрприз. RocksDB 11.8.1 оказалась немного медленнее 9.8.4 на данной прогретой нагрузке. На восьми потоках — 3,99 млн против 4,32 млн GET/с. Последовательная пакетная запись тоже не выиграла: около 1,71 млн против 1,84 млн записей/с на Python 3.14t без GIL.

Это не приговор новой RocksDB: данные помещались в память, мы не измеряли compaction, холодный SSD или сложный LSM-профиль. Но чисто по этим числам обновление движка не выглядело очевидным способом ускориться.

Главный вопрос оставался прежним: увидим ли 5× в настоящем HTTP-сервисе?

Эксперимент второй. In-process: потоки есть, ускорения почти нет

Первая архитектура выглядела перспективно для Python без GIL. HTTP-router и RocksDB находились в одном процессе, а операции GET передавались в пул потоков. Значит, конкурирующие запросы теоретически могли читать параллельно.

Прежде чем измерять производительность, пришлось решить неожиданную проблему окружения. В тестовой Windows-конфигурации не нашлось проверенного free-threaded wheel для orjson. Попытка сборки из исходников упёрлась в требуемую версию Rust toolchain.

Ставить обычный ABI-wheel, который мог бы снова включить GIL, было бы плохой идеей: эксперимент перестал бы проверять no-GIL.

Поэтому для всех трёх конфигураций HTTP-теста — A, B и C — мы использовали один ограниченный адаптер на стандартном модуле json. Он обеспечивал нужные для эксперимента операции сериализации и десериализации. Так сериализатор перестал быть переменной между A/B/C. Но появилась честная оговорка: абсолютные HTTP-результаты не являются цифрами исходного стека с native orjson.

Сервер обслуживал четыре физических shard с 25 000 ключей по 256 байт. Прикладной SLRU-кэш был отключён, а результаты запросов проверялись по значениям.

Одиночные HTTP GET

Запросы/с по распределённым ключам, значения округлены.
Запросы/с по распределённым ключам, значения округлены.

Это был момент, когда первоначальная гипотеза начала рассыпаться. На четырёх конкурирующих HTTP GET Python 3.14t без GIL оказался на 12,4% медленнее Python 3.12. При восьми запросах все варианты заметно деградировали относительно пика на двух-четырёх.

Хвостовые задержки тоже не улучшились. При concurrency 4 p99 вырос с 12,38 до 15,40 мс, при concurrency 8 — с 60,88 до 70,68 мс. Peak RSS поднялся с 78,9 до 99,4 МиБ, то есть примерно на 26%.

Куда делись пять раз?

Очевидная особенность настоящего HTTP GET — гораздо более длинный путь, чем у прямого вызова RocksDict. Между HTTP-пакетом и чтением ключа находятся разбор запроса, маршрутизация, состояние checkpoint, блокировки, планирование работы в пуле, JSON и формирование ответа.

При анализе архитектуры мы также увидели синхронные проверки актуальности checkpoint в пути запроса. Такие операции потенциально тормозят event loop ещё до того, как вызывается RocksDB. Но мы не измеряли вклад каждого этапа отдельно, поэтому не будем приписывать весь потолок QPS одному файловому обращению.

Batch GET оказался интереснее

Это ключи/с, а не HTTP-запросы/с.
Это ключи/с, а не HTTP-запросы/с.

На batch из 256 ключей при concurrency 2 отключение GIL дало +18,9% относительно того же Python 3.14t с GIL. Но по сравнению с обычным Python 3.12 выигрыш составил лишь +8,9%. При дальнейшем увеличении конкурентности эффект практически исчезал.

Получается, no-GIL действительно способен помогать пакетной обработке, если снижать накладные расходы на каждый ключ. Но этого недостаточно, чтобы автоматически сделать весь HTTP-сервис быстрее.

Эксперимент третий. Multiprocess: может, потоки вообще ни при чём?

Во второй архитектуре router не открывает все RocksDB-объекты в собственном процессе. Он направляет чтения в отдельные reader-процессы через IPC. На четыре физических shard приходится четыре независимых процесса чтения.

Именно здесь скрывается важное отличие: у каждого Python-процесса и без free-threading собственный GIL.

На уровне системы четыре worker могут выполнять native-операции одновременно уже под Python 3.12. Внутри отдельного worker чтения при этом обслуживаются последовательно: для batch используется синхронный MultiGet, а не восемь конкурирующих Python-потоков, как в первом опыте.

У нас возникло подозрение: возможно, free-threaded Python для такой архитектуры почти ничего не даст.

Сделали четыре A/B-конфигурации с одинаковым JSON-адаптером, четырьмя shard, четырьмя reader-worker, 15 000 распределённых плюс 5 000 одношардовых ключей, batch 256, конкурентностью HTTP 1/2/4, отключённым SLRU и одним набором восьми логических CPU.

Каждая конфигурация получила новую временную RocksDB. Восемь прогонов — четыре варианта, два повтора — завершились успешно. Проверялись не только времена, но и сами возвращённые значения.

Результаты полного HTTP + IPC пути

Batch throughput указан в ключах/с, p95/p99 — задержки HTTP batch-запросов.
Batch throughput указан в ключах/с, p95/p99 — задержки HTTP batch-запросов.

Максимальную абсолютную пропускную способность показал обычный Python 3.12 + RocksDB 9.8.4: 157,8 тыс. проверенных ключей/с при concurrency 4.

Python 3.14t без GIL и со старым движком получил 149,2 тыс., то есть −5,5% относительно базового варианта. С RocksDB 11.8.1 — 148,5 тыс., или −5,9%.

Если сравнивать только B и C, no-GIL всё-таки увеличил spread throughput на 3,5%, 3,7% и 7,3% при concurrency 1, 2 и 4 соответственно. Но этих процентов оказалось недостаточно, чтобы компенсировать разницу с Python 3.12.

На запросах к одному shard free-threaded режим даже немного уступал версии с включённым GIL. А RSS сервисов вырос примерно на 33%: 266,9 → 355,5 МиБ.

Разница p99 между 19,98 и 19,95 мс слишком мала, чтобы объявлять победу новой версии. На двух повторах такие десятки микросекунд нельзя интерпретировать как устойчивое преимущество.

И тут результат стал достаточно понятен: в этой схеме параллелизм между разделами уже организован процессами. Отключение GIL воздействует лишь на часть расходов внутри worker и router, но не сокращает HTTP/IPC-маршрут автоматически.

А как же новые асинхронные API RocksDB?

Обновление RocksDB до 11.8.1 было интересно не только как набор оптимизаций. Важный мотив — новые возможности асинхронного чтения на стороне C++.

Но есть несколько уровней «асинхронности», которые легко перепутать.

Асинхронный HTTP-server — это ещё не async RocksDB. Опции внутреннего перекрытия I/O для синхронного MultiGet — тоже не отдельный native coroutine API. Наконец, наличие новой функции в C++ вовсе не гарантирует, что она экспортирована через Rust/PyO3 в Python.

В исследованных сборках отдельного доступного Python-интерфейса для настоящих GetAsync/MultiGetAsync не обнаружили. Наши прогоны использовали обычный синхронный RocksDict MultiGet.

Поэтому заключение должно звучать строго: RocksDB 11.8.1 не дала существенного выигрыша для исследованной синхронной warm-cache-нагрузки. А вот производительность настоящего нативного async I/O мы не проверяли.

Если данные будут намного больше RAM и начнётся регулярное ожидание SSD, картина может измениться. Чтобы проверить это, нужен отдельно реализованный и подтверждённый async binding и I/O-bound профиль, а не тот же маленький набор из нескольких мегабайт.

Что осталось после экспериментов

Самое интересное — не то, что Python 3.14t где-то оказался медленнее. И не то, что новая RocksDB не выиграла в выбранном сценарии.

Интересно, как исчезает локальный выигрыш по мере расширения границы измерения.

Мы получили пятикратное ускорение RocksDB на Python без GIL. А потом запустили HTTP-сервис

Эти три значения не являются прямым сравнением абсолютной скорости архитектур. В первом случае один поток делает простой native GET; в остальных добавляются HTTP, сериализация, маршрутизация, checkpoint-логика или IPC. Но как три наблюдения за одной технологической гипотезой они показательны.

Что важно не забыть

Это короткие warm-cache испытания, а не универсальный performance-рейтинг. Два повтора на конфигурацию, синтетические данные, одинаковые восемь логических CPU и тестовая JSON-сериализация для HTTP. Данные умещались в RAM; отключение SLRU не означает физически холодного SSD.

Поэтому разницу в несколько процентов не стоит считать окончательным доказательством регрессии или ускорения на любом железе. Большие значения, BlobDB, compaction и реальный cold I/O остаются отдельными вопросами.

Вместо заключения

После первого теста с четырьмя миллионами GET в секунду было очень соблазнительно сразу перейти на Python без GIL.

Хорошо, что мы сначала измерили настоящий сервис.

В in-process архитектуре выигрыш обнаружился только в некоторых batch-режимах, а одиночный GET не ускорился. В multiprocess исходная конфигурация на Python 3.12 осталась быстрее и экономнее по памяти.

Поэтому для этого рабочего профиля мы сохранили прежний runtime и не стали менять формат баз ради новой RocksDB.

Оптимизация нижнего уровня может быть успешной — и одновременно почти бесполезной для пользовательского запроса. Именно это и стоит проверять до миграции, а не после неё.