Заголовок «4B-модель обгоняет Postgres на 81%» звучит как приговор оптимизатору, но за ним стоит конкретная конфигурация замера: 1.81x получено на 113 JOB-запросах при выборе лучшего из трёх прогонов на запрос, где каждый прогон — это отдельная траектория агента. Чтобы понять, что именно здесь измерено, нужно разобрать три вещи: как Postgres вообще строит план и почему ошибается, как устроен агент, который предлагает планы вместо планировщика, и как проводились замеры.
Что именно означает «81% быстрее»
Заявленное ускорение — это 1.81x, и в источнике оно приведено одновременно как геометрическое среднее ускорениеметрика, дающая всем запросам одинаковый вес: ускорение каждого запроса перемножается и из произведения извлекается корень по числу запросов и как суммарное ускорение по workloadотношение суммы времён базовых планов к сумме времён кандидатных планов, при котором весь набор запросов рассматривается как один пакет. Оба значения совпали: «When taking the best feedback across three rolloutОдин прогон текущей политики модели на запросе, в котором агент предлагает кандидатов и получает обратную связь.s, we saw a 1.81x geometric mean speedup, and coincidentally a 1.81x total workload speedup too.»
Это результат применения правила выбора лучшего фидбэка по трём траекториямпрогонам текущей политики модели на одном запросе на запрос, где каждая траектория может предложить до нескольких кандидатов, так что каждый запрос получает один ответ из до 15 кандидатов. Набор запросов — JOB, финальный чекпоинт RL на 1 200 обновлений. В таблице этому соответствует строка «Best feedback across all three»: S_geo 1.81x, S_workload 1.81x при 113/113 оценённых задачах.
Две метрики нужны потому, что показывают разные стороны результата. Суммарное ускорение — мера практической выгоды для аналитика, но оно может быть целиком обусловлено одним удачным планом, тогда как остальные запросы деградируют. Геометрическое среднее не зависит от абсолютных значений и даёт меру среднего ускорения по запросам: значения выше 1x означают, что средний запрос выполняется быстрее.
Какую модель использовали
Работает дистиллированная модель empero-ai/Qwen3.8-4B-Distill, созданная небольшой лабораторией Empero в Германии. Она получена дистилляцией знаний из модели Qwen 3.8 2.4T (учитель) в базовую Qwen 3.5 4B. Автор выбрал 4B-вариант, потому что в семействе Qwen 3.8 не было 4B-варианта, но он наткнулся на эту дистилляцию и заинтересовался. Дистиллированная модель не лучше базовой 3.5: она показывает лучшие результаты на MMLU и немного хуже на GSM8K. Автор решил остановиться на дистиллированной модели в любом случае.
Почему оптимизатор Postgres ошибается
Postgres не может точно подсчитать кардинальности во время планирования — для этого ему пришлось бы реально выполнить каждое соединение и подсчитать строки, что противоречит цели быстрой оптимизации. Вместо этого планировщик обращается к таблице pg_statistic, получая оттуда частые значения для каждого столбца и их частоты, а также гистограмму для остального.
Для соединений Postgres не знает, как строки одной таблицы распределены по другой, и обходит это предположение о равномерном распределениидопущение, что частота значения в первой таблице просто применяется ко второй таблице. Как эвристика это годится, но когда допущение неверно, ошибка бывает катастрофической. В примере фильтрация 2 млн записей movie_companies исходила из того, что 5% из них — японские компании, и первый join давал около 100k строк. Если же эти 5% японских компаний на самом деле ответственны за 50% фильмов, первый join произведёт 1 млн строк, тогда как второй порядок соединения отправит дальше только 400k строк. Одна плохая оценка на раннем соединении каскадно искажает все остальные оценки в дереве соединений.
Postgres не перебирает все планы: он использует динамическое программирование, а для запросов с 12 и более соединениями — генетический алгоритм, чтобы сократить пространство поиска. План всегда выбирается с наименьшей стоимостью, и изменить стоимостную модель без правки исходного кода нельзя.
Комбинаторный взрыв пространства планов
Число рассматриваемых планов получается перемножением четырёх множителей: числа join-деревьев, числа ориентацийвозможностей для каждого соединения поменять местами внешний и внутренний входы, числа алгоритмов соединения и числа способов сканирования. Для 3 таблиц: 2 join-дерева × 2² ориентаций × 3² алгоритмов × 4³ сканов = 4 608. Для 4 таблиц: 8 × 2³ × 3³ × 4⁴ = 442 368. Для 5 таблиц: 25 × 2⁴ × 3⁴ × 4⁵ = 33 177 600.
2 Join trees × 2² Orientations × 3² Algorithms × 4³ Scans = 4,608Ориентация — это отдельный множитель, хотя число строк на выходе от неё не меняется. Коммутативность влияет на производительность, потому что от ориентации зависит, какой алгоритм соединения применяется. Алгоритм соединения может быть hash join, merge join или nested loop; сканирование каждой таблицы — последовательным, индексным, index-only или bitmap.
Как агент предлагает план
Агенту на вход подаётся запрос и план, который Postgres выбирает сам. Агент может вызвать evaluate_candidate, передавая одно предложение по плану. ХарнессОбвязка, которая принимает предложения агента по плану, проверяет их на реальной базе через EXPLAIN, компилирует в hints и измеряет время выполнения. — обвязка, которая принимает предложения агента и проверяет их на реальной базе, — проверяет кандидата обычным EXPLAIN, компилирует его в hints и при включённой обратной связи выполняет запрос один раз после прогрева и сообщает время. Компиляция в hints означает, что выбранные агентом решения переводятся в подсказки для планировщика, которые заставляют Postgres воспроизвести предложенный план.
В ответ агент получает id кандидата, измеренное время и предварительное отношение к медиане default, а также число оставшихся попыток. Каждая отправка, валидная или нет, расходует одну попытку кандидата. Когда попытки исчерпаны, агенту предлагается только finish, который завершает поиск, называя кандидата, включая более раннего, после чего харнесс измеряет его против default финальным парным протоколом. В примере траектории агенту разрешено предложить до трёх кандидатов.
Решения оптимизатора не извлекаются из вывода EXPLAIN автоматически: модель сама формулирует структуру плана. Поле leading задаёт дерево соединений — сначала соединить cn с mc, затем результат с t. Поле joins с force задаёт принудительный hash join для конкретного шага. Поле scans с force задаёт способ доступа — например, индексный скан для внутренней стороны цикла.
Кандидат считается дублирующим дефолтный план, если их EXPLAIN-выводы без стоимости и оценок строк эквивалентны. Девять из пятнадцати валидных траекторий дают score 1.00x, потому что план был идентичен дефолтному или модель решила оставить дефолт.
Как управлять слоном: hints и pg_hint_plan
Блок подсказок — это обычный SQL-комментарий, начинающийся с символа +, поэтому Postgres его игнорирует. Внутри блока указываются подсказки: HashJoin(a b) предписывает соединить a и b через hash join, SeqScan(a) — читать таблицу a последовательным сканированием вместо индекса. Поддерживаются подсказки метода соединения, метода сканирования и порядка соединения.
Бенчмарки
Использовались два бенчмарка на одной базе IMDb: Join Order Benchmark (JOB) и Cardinality Estimation Benchmark (CEB). JOB состоит из 113 запросов, разбитых на 33 шаблона, причём каждый шаблон имеет уникальный граф соединений (33 топологии). Например, JOB 6 — 6 запросов на 5 таблиц, JOB 7 — 3 запроса на 8 таблиц, JOB 8 — 4 запроса на 7 таблиц, JOB 9 — 4 запроса на 8 таблиц.
CEB крупнее: 13 646 синтетически сгенерированных запросов, 16 шаблонов и 12 топологий. Например, CEB 9a — 2 247 запросов на 9 таблиц, CEB 9b — 537 запросов на 9 таблиц, CEB 10a — 1 019 запросов на 7 таблиц. CEB использовался для обучения модели, а JOB — для проверки. CEB-запросы с топологией, совпадающей с JOB, удалялись бы из обучающего набора, чтобы не переобучаться на шаблоны JOB; проверка показала, что пересечений нет, поэтому фильтрация не потребовалась.
Шаблон запроса — это обобщённая формулировка, из которой получаются конкретные запросы. Шаблон 2 звучит как «What is the alphabetically first title of a movie associated with a company from country X and tagged with the keyword character-name-in-title?». В нём есть переменная часть — страна X — и фиксированная — ключевое слово. Конкретные варианты получаются подстановкой разных значений: запрос 2a — для немецкой компании, 2d — для американской. В SQL-коде они отличаются только константой в условии cn.country_code: '[de]' для 2a, '[us]' для 2d.
Как сравнивали и что получилось
Методология замеров
Перед экспериментами тюнились два параметра памяти Postgres: shared_buffers (сколько базы Postgres держит в собственном кэше) и work_mem (сколько памяти получает одна операция сортировки/хеширования до сброса на диск). Было проведено четыре калибровки с комбинациями 128 МБ/2 ГБ для shared_buffers и 4 МБ/32 МБ для work_mem. Оказалось, что work_mem вообще не влиял на шум, а весь эффект давал shared_buffers: при 2 ГБ медианный запрос заканчивал прогрев со счётчиком SRBshared read blocks — счётчик страниц, которые пришлось запрашивать у Linux, а не брать из кэша Postgres ровно на нуле, рабочий набор полностью помещался в кэш Postgres. В итоге автор зафиксировал 2 ГБ shared_buffers и 4 МБ work_mem на весь остаток проекта.
«Прогретость» запроса оценивается по двум счётчикам из вывода EXPLAIN с опцией BUFFERS: shared hit blocks увеличивается каждый раз, когда страница найдена в собственном кэше Postgres shared_buffers, а shared read blocks — когда страницу приходится запрашивать у Linux. После каждого прогревочного прогона значения hit и read сравниваются с предыдущим прогоном; если оба отличаются не более чем на 2% и план не изменился, запрос считается прогретым и начинается измерение. Для сравнения нужно минимум два прогрева, а максимум — пять. Критерий «остановки счётчиков» означает лишь стабилизацию значений, а не полную резидентность данных: при малом shared_buffers SRB могут оставаться большими.
Одиночного запуска плана недостаточно, потому что измерения шумят: у запроса job-13b из 20 прогонов 14 попали в диапазон 186–204 мс, а 6 — в 227–253 мс, что на 14–26% медленнее. Поэтому при реальных прогонах агента выполнялись три чередующиеся пары (candidate, default) последовательно. Из трёх времён кандидата и трёх времён дефолта брались медианы, и их отношение давало итоговое ускорение или замедление, а если медианы различались меньше чем на 5%, объявлялась ничья.
CV считался как стандартное отклонение 20 прогонов, делённое на их среднее и умноженное на 100%, и для job-13b составил 10.3%. Но CV ненадёжен как целевая метрика: он строится на среднем и легко смещается несколькими выбросами, а два запроса с одинаковым CV могут обманывать награду с разной частотой в зависимости от формы разброса. Минимизировать следует саму частоту обмана — fooling rateдолю замеров, в которых шум заставляет принять один план за другой.
После тюнинга при 2 ГБ shared_buffers и 4 МБ work_mem no-op error rateдоля замеров, в которых один и тот же план сравнивается сам с собой и всё равно даёт расхождение составил 1.8% / 1.2% (прогоны 1 и 2), p90 query — 1.3% / 0%, медианный CV — 1.1%.
Регрессии учитываются через клиппирование отношения медиан: после выбора кандидата запускаются три чередующиеся пары, и результат делится с ограничением диапазона [0.1, 10]. Клиппер не даёт геометрическому среднему сильно смещаться из-за экстремального ускорения или экстремальной регрессии.
Обучение
Учителем выступает более умная и крупная модель — в примере прямо назван GPT-6 Astra. Для сбора данных автор сгенерировал 120 траекторий Astra по случайной выборке запросов из CEB с включёнными reasoning summaries: 100 пошло на обучение, 20 — на held-out валидацию. Все траектории были отрендерены библиотекой Prime Intellect renderers в формат Qwen, замаскированы по loss, развёрнуты и упакованы в обучающие демонстрации; после unrollingразвёртывания траектории в последовательность обучающих примеров и packingупаковки нескольких примеров в одну обучающую последовательность 100 траекторий Astra дали 382 training rows.
Рендеринг — это преобразование текста траектории в сырую последовательность токенов, понятных конкретной модели. loss maskingразметка того, какие токены траектории студент обязан предсказывать, а какие служат только контекстом помечает, какие части траектории являются только контекстом, а какие студент обязан предсказывать: модель не порождает системный промпт, пользовательские промпты и результаты вызова инструментов, но должна их видеть как контекст. Траектория разбивается на пары (context, reply), где контекст аддитивен и включает предыдущие ответы модели. Одна траектория с тремя ответами ассистента превращается в три обучающих примера. При обучении на одной RTX 3090 траектория не должна превышать ~50k токенов, иначе возможен OOM из-за 24 GB VRAM.
LoRA выбрана потому, что полное обновление всех параметров модели требует больше памяти, чем было доступно: для одного прохода при SFT понадобился бы GPU с не менее 64 ГБ VRAM, тогда как у автора были потребительские RTX 3090 по 24 ГБ. LoRA замораживает исходные веса и обучает лишь небольшую пару матриц, произведение которых даёт поправку к выбранным весам. Обученный LoRA-адаптер занял всего 42.5 МБ и содержал 21.2 млн обучаемых параметров против 4.66 млрд параметров модели.
Траектории завершались по-разному: «No valid candidate» — 81 запрос, «Selection failed» — 16, «Timed out» — 1, «Candidate duplicated the default plan» — 7, «Kept the default» — 2, «Candidate measured against the default» — 6. Вклад в score дают только последние три строки, поэтому из 113 валидными остаются 15 траекторий; девять из этих 15 по построению дают score 1.00x.
Для каждого запроса выполняется группа rollout'ов, и каждый прогон получает оценку качества. baselineопорное значение, с которым сравнивается качество прогона, чтобы награда не доставалась просто за «наименее плохой» результат для rollout'а — это максимум из нуля и среднего качества его «соседей» по группе, то есть всех остальных rollout'ов того же запроса, у которых вообще есть оценка качества. Это нужно, чтобы rollout не получал положительное преимущество просто за то, что он «наименее плохой» в плохой группе. Само относительное преимущество считается как разность качества и baseline, а затем корректируется: при выборе кандидата, дублирующего план Postgres без явного keep_defaultуказания сохранить дефолтный план, вычитается 0.02, а при завершении без валидного кандидата — 0.1. В примере с четырьмя rollout'ами два упавших получают преимущество −0.10, NestLoop −0.18, HashJoin −0.02 — все четыре получают отрицательное преимущество, то есть их вероятность понижается.
Инфраструктура
Тренировочный цикл разделён между двумя машинами: измерения Postgres выполняются на FLOPper, а инференс и обучение — на арендованном узле с 2x H100. На FLOPper работают четыре Postgres-контейнера, и роллауты арендуют доступный воркер только на время, когда нужны измерения, что позволяет запускать 20 роллаутов одновременно, конкурирующих за одни и те же четыре воркера. Инференс роллаута идёт через vLLM на первой H100 для генерации траектории, а результаты роллаута отправляются на вторую H100 для вычисления относительных преимуществ и обновления весов. Один шаг/роллаут в визуализации показан как 240 секунд wall time.
При старой схеме rollout'ы удерживали доступный контейнер Postgres на протяжении всего своего жизненного цикла, что ограничивало число одновременных rollout'ов числом доступных воркеров. Переход к аренде воркера только на время измерений позволил запускать 20 одновременных rollout'ов и повысил долю времени фактического использования воркеров. Это увеличивает пропускную способность обучения: 92% времени rollout'а уходило на vLLM inference, и новая асинхронная схема позволяет сильнее насытить vLLM и тренер.
Результаты по чекпоинтам
По чекпоинтам SFT менялись так:
| Чекпоинт | Valid candidate | Tasks scored | S_geo | S_workload |
|---|---|---|---|---|
| 1 эпоха | 48/113 | 44/113 | 0.72x | 0.76x |
| 2 эпохи | 85/113 | 1.08x | 1.04x | |
| 3 эпохи | 57/113 | 99/113 | 0.82x | 0.89x |
| +1 эпоха на новых 300 | 1.10x | 1.05x | ||
| +2 эпохи на новых 300 | 71/113 | 1.16x | 1.06x |
Заявленный результат 1.81x geometric mean speedup и 1.81x total workload speedup достигнут на финальном 1 200-update RL чекпоинте при выборе лучшего feedback по трём траекториям на запрос. При этом 44.7% latency reduction указано как общий итог по 113 join-heavy queries.
Сравнение конфигураций выбора
| Конфигурация | Tasks scored | S_geo | S_workload | Wins | Regressions |
|---|---|---|---|---|---|
| Model's own choice, per trajectory | 339/339 | 1.40x | 1.24x | 119 | 7 |
| Best feedback within each trajectory | 339/339 | 1.44x | 1.29x | 129 | 1 |
| Best feedback across all three | 113/113 | 1.81x | 1.81x | 68 | 0 |
У финальной модели в строке «Best feedback across all three» — 68 wins и 0 regressions при 113 оценённых задачах. Это и есть та конфигурация, где правило выбора применяется сразу по трём траекториям на запрос, поэтому на каждый запрос берётся один ответ из максимум 15 кандидатов.
Оговорки
Автор указывает, что обучение и замеры проводились на конкретном оборудовании: четыре рабочих процесса Postgres на FLOPper, вывод vLLM — на H100. Оценка велась на бенчмарке JOB из 113 запросов, а финальный замер сделан в режиме best-of-15 при трёх попытках на запрос. Автор прямо оговаривает, что итоговые цифры ускорения не доказывают, что модель научилась чему-то содержательному: суммарное ускорение по нагрузке может быть целиком обязано одному удачному плану, а остальные запросы могут быть деградировавшими. Поэтому для измерения реального обучения используется геометрическое среднее ускорение.
Что из этого следует на практике
Оптимизатор Postgres оценивает кардинальности по статистике, а не считает их, и для соединений применяет предположение о равномерном распределении. Когда это предположение неверно, ошибка на раннем соединении каскадно искажает всё дерево — именно на таких запросах агент и получает преимущество. При этом пространство планов растёт комбинаторно: уже для 5 таблиц это 33 177 600 вариантов, а Postgres сокращает поиск динамическим программированием, а с 12 соединениями — генетическим алгоритмом.
Агент не переписывает стоимостную модель — он формулирует структуру плана (дерево соединений, методы соединения, способы сканирования), которая компилируется в hints для pg_hint_plan. Валидность проверяется обычным EXPLAIN, а дубликат дефолтного плана распознаётся по эквивалентности EXPLAIN-выводов без стоимости и оценок строк.
Заявленное ускорение 1.81x получено в конкретной конфигурации: финальный RL-чекпоинт, три траектории на запрос, выбор лучшего из до 15 кандидатов, 113 JOB-запросов, 2 ГБ shared_buffers и 4 МБ work_mem, четыре Postgres-воркера на FLOPper. В конфигурации, где модель выбирает сама по одной траектории, ускорение скромнее — 1.24x суммарного ускорения по workload при 7 регрессиях. Разница между этим результатом и 1.81x — это цена выбора лучшего из нескольких прогонов, а не свойство самой модели.
Стоимость проекта автор оценивает как почти нулевую, если не считать электричества на непрерывную работу FLOPper при полной загрузке обеих GPU — около $9 в день. Дополнительные $1 200 (около $800 за аренду узла 2x H100 SXM у Lambda примерно на 95 часов и около $400 за API OpenAI для генерации демонстраций траекторий Astra) он объясняет нетерпением получить результаты обучения быстрее.