Блог

PostgreSQL 19 отключает JIT по умолчанию

Юрий Мельников

PostgreSQL 19 отключает JIT по умолчанию. Разбираемся, почему оптимизация стала риском.

Ранее я писал о запросе, который выполнялся около 52 секунд с JIT и примерно 7 секунд без него. Тогда это выглядело как неприятный частный случай: планировщик счёл запрос достаточно дорогим для JIT-компиляции, но почти всё сэкономленное время съел LLVM.

Теперь этот случай получил неожиданное продолжение. В PostgreSQL 19 JIT будет отключён по умолчанию. На момент написания статьи PostgreSQL 19 находится в стадии Beta 3, поэтому использовать его в продакшене ещё рано. Но само решение уже вошло в ветку будущего релиза, а в release notes причина сформулирована прямо: прежняя модель стоимости оказалась недостаточно надёжной.

Получается почти полный круг. JIT появился в PostgreSQL 11 выключенным, в PostgreSQL 12 его включили по умолчанию, а в PostgreSQL 19 дефолт снова меняется на off.

Это не означает, что JIT оказался бесполезным. Это означает другое: база данных больше не пытается автоматически угадать, окупится ли компиляция, когда цена ошибки может быть слишком высокой.

Что именно меняется в PostgreSQL 19

В PostgreSQL 18 параметр jit по умолчанию имеет значение on. При этом JIT запускается не для каждого запроса, а только когда оценочная стоимость плана превышает jit_above_cost. Значение порога по умолчанию — 100000.

В PostgreSQL 19 значение jit по умолчанию меняется на off. Остальная механика никуда не исчезает: JIT можно включить глобально, для конкретной роли, сессии, транзакции или запроса.

Важно проверить реальную конфигурацию своей базы, а не делать вывод только по номеру версии:

SHOW server_version;
SHOW jit;
SHOW jit_above_cost;
SHOW jit_inline_above_cost;
SHOW jit_optimize_above_cost;

Обновление PostgreSQL само по себе не обязано переписать уже существующую конфигурацию. Новый дефолт прежде всего задаёт безопасное начальное поведение. Поэтому после обновления всё равно стоит выполнить SHOW jit и посмотреть фактическое значение.

Почему cost-based решение не сработало

PostgreSQL решает, нужен ли JIT, сравнивая общую стоимость плана с тремя порогами:

  • jit_above_cost включает генерацию кода;

  • jit_inline_above_cost разрешает inlining функций и операторов;

  • jit_optimize_above_cost включает более дорогие оптимизации LLVM.

По умолчанию это 100000, 500000 и 500000 соответственно.

Проблема в том, что planner cost — не время выполнения и не прогноз latency. Это условная величина, с помощью которой планировщик сравнивает альтернативные планы. Она зависит от оценок количества строк, стоимости операций, чтения страниц и других факторов, но не знает, сколько миллисекунд LLVM потратит на генерацию и оптимизацию кода именно на этом сервере.

В итоге два разных решения принимаются на основании одной величины:

  1. Планировщик выбирает предположительно самый дешёвый план.

  2. По итоговой стоимости этого плана PostgreSQL решает, запускать ли JIT.

Для первой задачи относительная стоимость обычно полезна. Для второй нужен ответ на другой вопрос: успеет ли ускорение выполнения окупить компиляцию? Надёжно ответить на него planner cost не может.

Из-за этого возникает performance cliff. Небольшое изменение статистики или объёма таблицы поднимает стоимость плана чуть выше порога. Сам план почти не меняется, но внезапно появляется дополнительный этап компиляции. Запрос, который вчера укладывался в десятки миллисекунд, сегодня может стать заметно медленнее.

Именно это разработчики PostgreSQL указали при смене дефолта. В обсуждении также отмечалось, что распространение партиционирования увеличило число выражений, которые приходится компилировать, а LLVM со временем стал тратить на эту работу больше времени. Для коротких и средних запросов риск проигрыша оказался важнее возможного автоматического выигрыша.

JIT не компилирует запрос целиком

Название легко создаёт неверную картину: будто PostgreSQL превращает весь план запроса в одну нативную программу. На практике JIT ускоряет более узкие участки выполнения:

  • вычисление выражений в WHERE, target list, агрегатах и проекциях;

  • tuple deforming — преобразование строк из внутреннего формата PostgreSQL в значения, с которыми работает executor;

  • inlining некоторых коротких функций и операторов;

  • оптимизацию сгенерированного кода средствами LLVM.

JIT не делает плохой план хорошим. Он не добавляет индекс, не исправляет ошибочную оценку количества строк и не отменяет дорогое чтение с диска. Если запрос упирается в I/O, блокировки или передачу данных по сети, выигрыш от компиляции выражений может быть минимальным.

Лучший кандидат для JIT — длинный CPU-bound запрос, который много раз вычисляет одни и те же нетривиальные выражения на большом количестве строк. Именно поэтому JIT чаще полезен в аналитике, чем в OLTP.

Как понять, что проблема именно в JIT

Начать стоит с плана выполнения:

EXPLAIN (ANALYZE, BUFFERS)
SELECT ...;

Если JIT использовался, в результате появится отдельный блок:

JIT:
  Functions: ...
  Options: Inlining ..., Optimization ..., Expressions ..., Deforming ...
  Timing: Generation ... ms, Inlining ... ms,
          Optimization ... ms, Emission ... ms, Total ... ms

Здесь полезно смотреть не только на Total, но и на распределение времени:

  • Generation — создание промежуточного кода;

  • Inlining — встраивание функций;

  • Optimization — оптимизации LLVM;

  • Emission — генерация машинного кода.

Самый простой контрольный эксперимент — выполнить один и тот же запрос с JIT и без него:

BEGIN;

SET LOCAL jit = off;

EXPLAIN (ANALYZE, BUFFERS)
SELECT ...;

ROLLBACK;

Затем повторить измерение с SET LOCAL jit = on.

Одного запуска недостаточно. Первый запрос может читать данные с диска, а повторный — уже из кэша. Для честного сравнения нужны несколько запусков в одинаковых условиях, одинаковые параметры и проверка не только среднего времени, но и разброса. На продакшене EXPLAIN ANALYZE тоже следует использовать осторожно: он действительно выполняет запрос.

Prepared statements добавляют ещё один сюрприз

Решение о JIT принимается при построении плана, а не в момент выполнения. Это особенно важно для prepared statements и connection pool.

Если PostgreSQL использует generic plan, на решение влияют настройки, действовавшие при подготовке плана. Изменить пороги перед очередным EXECUTE может оказаться недостаточно: старый generic plan уже содержит принятое решение. Параметр jit = off действует и на стадии планирования, и на стадии выполнения, но при диагностике всё равно важно учитывать жизненный цикл подготовленного плана.

Практический вывод: проверять JIT нужно через то же подключение и тем же способом, которым запрос выполняется в приложении. Тест из отдельного psql может не воспроизвести поведение пула соединений.

Как я бы настраивал JIT теперь

Для типичного backend-приложения с короткими запросами я бы выбрал jit = off как базовое значение. В PostgreSQL 19 это станет штатным дефолтом, но ту же политику можно применить уже сейчас на PostgreSQL 18 и более ранних поддерживаемых версиях.

Точечная настройка для роли приложения:

ALTER ROLE app_user SET jit = off;

Для отдельной аналитической роли:

ALTER ROLE analytics_user SET jit = on;

Так OLTP-трафик не платит за неожиданные компиляции, а тяжёлые аналитические запросы сохраняют возможность использовать JIT. В смешанной нагрузке это обычно понятнее и предсказуемее, чем один глобальный порог для всех приложений.

Если JIT действительно помогает аналитике, пороги стоит подбирать на реальных запросах и реальном железе. Универсального значения нет: стоимость компиляции зависит от сложности выражений, количества сгенерированных функций, версии LLVM и процессора, а потенциальный выигрыш — от характера нагрузки.

Есть и промежуточный вариант: оставить базовую генерацию кода, но не включать дорогие оптимизации и inlining. Для этого не обязательно менять jit; можно отдельно управлять jit_inline_above_cost и jit_optimize_above_cost. Но это тоже настройка, которую надо подтверждать измерениями, а не ощущением, что «немного JIT» всегда безопаснее.

Что в итоге

Когда я впервые столкнулся с замедлением с 7 до 52 секунд, выключение JIT выглядело почти как обходной путь. Теперь PostgreSQL 19 делает jit = off новым дефолтом по той же причине: автоматическое решение на основе planner cost иногда ошибается слишком дорого.

Это важный, но не окончательный приговор технологии. JIT по-прежнему способен ускорять большие CPU-bound запросы. Меняется ответственность: раньше PostgreSQL пытался включить его автоматически, а теперь пользователю предлагается сначала доказать пользу на своей нагрузке.

На мой взгляд, это более честный дефолт. Предсказуемая производительность важнее оптимизации, которая иногда срабатывает блестяще, а иногда превращает небольшое изменение данных в неожиданный скачок latency.

Короткое правило теперь звучит так: для OLTP начните с jit = off; для аналитики включайте JIT после измерений; при внезапном замедлении всегда проверяйте блок JIT в EXPLAIN ANALYZE.

Источники