Блог

JIT-компіляція в PostgreSQL

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

Сьогодні зіткнувся з контрінтуїтивною ситуацією, коли JIT-компіляція не тільки не допомагає, але й шкодить! Як таке можливо, що при увімкненій JIT-компіляції час виконання запиту збільшується вісім разів? Спробуємо розібратися.

Трохи історії

JIT (Just-In-Time compilation) у PostgreSQL з'явився як спроба прискорити виконання дорогих запитів за рахунок компіляції частини плану виконання в нативний машинний код. Ідея в тому, щоб відійти від інтерпретації виразів (фільтрів, обчислень, агрегатів) і виконувати їх максимально близько до «заліза». Для цього PostgreSQL використовує LLVM, який на льоту генерує та оптимізує машинний код під конкретний запит.

JIT задумувався передусім для CPU-інтенсивних аналітичних сценаріїв: складні вирази, великі обсяги даних, тривалі запити, де вартість компіляції окупається часом виконання. У таких випадках виграш досягається за рахунок зниження overhead інтерпретатора та більш агресивних оптимізацій, які неможливо або недоцільно робити заздалегідь. В OLAP-навантаженні це дійсно може дати відчутний приріст — але тільки якщо запит «достатньо важкий», щоб JIT встиг себе виправдати.

JIT проєктувався не під типовий OLTP.

JIT з'явився в PostgreSQL 11 як експериментальна та вимкнена за замовчуванням можливість. Починаючи з PostgreSQL 12, JIT увімкнений за замовчуванням (jit = on), але з важливою застереженням: він спрацьовує тільки при перевищенні порогів вартості (jit_above_cost, jit_inline_above_cost, jit_optimize_above_cost).

Розробники PostgreSQL виходили з того, що PostgreSQL давно і активно використовують як OLAP або аналітичну БД: звіти, ad-hoc аналітика, важкі SELECT з агрегаціями і так далі. Такі запити виконуються секунди і хвилини, містять складні вирази, рідше запускаються, але «палять» процесор. Саме в цьому сценарії вартість JIT-компіляції тоне в загальному часі виконання і прискорення виразів і фільтрів має сенс.

І ось тут починається розходження з реальністю.

На практиці PostgreSQL найчастіше використовують як OLTP-базу для веб-додатків: CRUD, API, бекенди, мікросервіси. Переважні характеристики таких навантажень — це короткі запити (мілісекунди), висока частота, багато простих SELECT / INSERT / UPDATE. Для цього класу навантажень overhead JIT часто не окупається і навіть «дорогий» за planner cost запит може бути коротким по факту, а компіляція + LLVM може займати порівнянний час з execution.

Універсальність PostgreSQL зіграла проти нього самого.

PostgreSQL одночасно OLTP і OLAP, активно використовується в аналітиці і часто розгортається як «універсальна БД». Тому було прийнято рішення увімкнути JIT глобально, але обмежити його через cost-based thresholds і перекласти фінальне налаштування на інженерів під конкретне навантаження. Це розумно з точки зору ядра СУБД, але небезпечно в продакшені, якщо не дивитися EXPLAIN ANALYZE.

У PostgreSQL важливо розуміти одну річ: planner cost — це не час і навіть не спроба його вгадати. Це абстрактна модель відносної «дороговизни» плану, що використовується тільки для вибору плану, а не для оцінки latency.

Planner cost вимірюється в умовних одиницях, не прив'язаний до мілісекунд, не враховує реальну продуктивність CPU і не знає нічого про поточне навантаження системи. Це означає, що він представляє собою грубі евристики, а не вимірювання.

Planner Cost добре працює, коли план виконується довго і різниця між варіантами плану значна, але для коротких запитів, де execution займає 5–20 ms, JIT додає 10–30 ms, а planner все ще вважає це «дорогим» запитом. З точки зору cost це «окупиться», а з точки зору користувача стало об'єктивно повільніше.

Як правильно обмежувати або вимикати JIT у PostgreSQL

Важливо відразу зафіксувати: JIT — не бінарний вибір. Його не обов'язково або «вмикати всім», або «вимикати назавжди». У PostgreSQL є кілька рівнів контролю — від акуратного налаштування до повного вимкнення.

Найбезпечніший варіант: вимикати JIT на рівні сесії. Підходить для OLTP-сервісів, API, фонових воркерів і batch-задач з передбачуваними запитами. Це добре, тому що не впливає на інші підключення, легко увімкнути назад і можна застосовувати точково (connection pool, role, додаток). Часто це оптимальний дефолт для backend-сервісів.

JIT — це не одна кнопка, а набір стадій: генерація, inlining, оптимізація, emission. У деяких сценаріях розумно залишити JIT увімкненим, але вимкнути найдорогіші стадії. Це знижує overhead, зберігаючи частину виграшу для великих запитів.

Повне глобальне вимкнення — крайній варіант і має сенс, якщо немає аналітичних запитів, CPU дорогий ресурс або база чисто OLTP. Це чесне і валідне рішення, а не «антипатерн».

Особистий досвід

Сьогодні зіткнулися з показовою ситуацією в PostgreSQL: складний розрахунковий VIEW з кількома JOIN, великою кількістю CASE, round() і numeric-арифметики виконувався близько 52 секунд. На перший погляд — типовий «важкий» запит. Однак EXPLAIN ANALYZE показав, що реальна проблема була не в даних і не в сортуванні, а в JIT-компіляції: близько 47 секунд пішло виключно на LLVM JIT (тисячі згенерованих функцій, десятки секунд на оптимізацію і emission). Execution як такий займав лише невелику частину загального часу.

Рішення виявилося тривіальним: SET jit = off. Без переписування запиту, без індексів і без зміни логіки час виконання скоротився з ~52 до ~7 секунд. Цей кейс добре ілюструє, що cost-based увімкнення JIT не гарантує виграшу за часом: planner cost — це абстрактна модель, а не latency-метрика.

JIT у PostgreSQL — потужний інструмент, але за замовчуванням він оптимізує абстрактну вартість, а не реальний час. У продакшені виграє той, хто це розуміє і керує ним усвідомлено.