Векторизация исполнения запросов обычно означает отдельный движок рядом с PostgreSQL: свой парсер, свой планировщик, свой обмен данными между процессами. Здесь разбирается другой путь — векторный исполнитель внутри самого бэкенда, подключаемый расширением и не меняющий ядро. Вопрос нетривиален тем, что правила проекта запрещают правки ядра и требуют, чтобы без загруженного расширения сервер вёл себя как ванильный, а готовый план никто не переписывал.
Зачем векторизация без форка ядра
Векторные сканы, фильтры и агрегаты можно реализовать внутри бэкенда PostgreSQL с семантикой PostgreSQL и без копирования между процессами — именно эти части движка названы самыми выгодными для векторизации. Поэтому автор выбрал не ещё один движок рядом с PostgreSQL, а векторный исполнитель внутри самой СУБД.
Два правила задают форму решения. Первое: исполнитель подключается только через существующие хуки, без изменений ядра, и без расширения сервер работает как ванильный. Второе: векторные узлы доступны планировщику и конкурируют с построчными альтернативами по стоимости, а готовый план никто не переписывает.
Предшественники — вендорские векторизованные исполнители. У закрытого движка Cloudberry открытых исходников нет: остались лишь следы — флаг create_vectorization_plan, который открытый планировщик всегда передаёт как false, узел WindowHashAgg без исполнителя за ним и адаптер PAX под VEC_BUILD, давно не компилирующийся. Из openGauss, Hydra и TimescaleDB код не копируется. Без векторизованного исполнителя Cloudberry — это форкизменённая копия ядра, развиваемая отдельно от основной ветки PostgreSQL: порт несёт 24 патча-хука. Для pg_vexec в порте Cloudberry нужны только те изменения ядра, которые требует сам порт.
Как расширение встраивается в планировщик
vexec добавляет векторные пути через собственные хуки планировщика PostgreSQL. Хук set_rel_pathlist_hook вызывается, когда планировщик строит пути доступа к отдельному отношению, и vexec регистрирует в нём VecScan — векторный путь сканирования. Хук set_join_pathlist_hook вызывается при построении путей соединения, и в нём регистрируется VecHashJoin. Хук create_upper_paths_hook вызывается для верхних узлов плана, и в нём регистрируются VecAgg и VecSort. Векторный путь — это альтернативный способ выполнить узел, обрабатывающий данные батчами, а не построчно. Дальше векторные пути конкурируют с построчными в обычном add_path. Стоимость векторного пути выводится из стоимости построчного с множителями для работы ядер, а переходы между строками и батчами выбираются по стоимости.
Эти хуки не могут дотянуться до планов ORCA: ORCA строит собственный PlannerInfo и возвращает готовый план. Поэтому порт использует небольшой API в gp_orca, через который vexec регистрирует свой oracleкомпонент vexec, который по стоимости решает, заменять ли готовый узел плана на векторный, оценки стоимости и построители узлов. CCostModelVec, подкласс CCostModelGPDB, дважды вычисляет формулы ORCA — как есть и с векторными множителями — и смешивает их по доле шагов, уходящих в ядра. Благодаря этому ORCA выбирает порядок соединений, стадии агрегации и Motions уже с учётом векторизованного выполнения.
Готовый план ORCA получается так: транслятор предлагает vexec каждый завершённый узел, и vexec заменяет его на векторный, если oracle согласен, — до проверки Motions, создания slice table и проходов M8, так что все последующие шаги порта видят финальное дерево. Хешированная оконная агрегация ORCA, для которой в PostgreSQL 19 нет исполнителя, становится VecWindowHashAgg под обычным WindowAgg.
Режимы работы: auto, force, explain
Режим задаётся через SET vexec.mode. В режиме auto векторные пути участвуют в выборе по стоимости — именно в этом режиме vexec снимает 19–24% с планировщика PostgreSQL.
В режиме explain векторные пути не выбираются. EXPLAIN (VEXEC, COSTS OFF) показывает для каждого кандидата пометку not chosen (explain mode) и детали: для VecScan — источник (heap's pages), число kernel и fallback steps для quals и target; для VecAgg — hashed, число группировочных колонок, агрегатов и сколько из них с векторными переходами; для VecSort — число ключей сортировки. Сам план при этом остаётся обычным: Sort, HashAggregate, Seq Scan с Filter. Вывод содержит обычный план и дополнительный блок с пометкой Vexec: mode explain, format postgres, где перечислены невыбранные векторные узлы.
Два in-memory формата данных
vexec использует два in-memory форматаспособа раскладки значений батча в памяти. Формат postgres хранит значения так, как их определяет PostgreSQL, arrow — в стандартных типах Arrowформат колоночного представления данных, в котором значения хранятся в типизированных буферах и которым пользуются библиотеки аналитической обработки. Второй формат нужен, чтобы данные не приходилось преобразовывать на границах Arrow: формат postgres не платит за границы PostgreSQL (fmgr, slots, tuplesort, hash functions, heap, ao_column, porc), а arrow не платит за свои границы (export, Flight SQL, porc_vec, frames между процессами). Выбор задаётся настройкой vexec.batch_format, по умолчанию postgres. Конвертации происходят только на этих именованных границах, и их стоимость учитывается в модели стоимости. На ClickBench два формата различались не более чем на 2.6%.
Различия форматов: bool — байт на значение против бита; date и timestamp — эпоха PostgreSQL 2000-01-01 против Unix-эпохи; interval — 16 байт PostgreSQL против month_day_nano; text и bytea — Datum на значение с varlena-заголовком против utf8_view или binary_view. При этом int, float, uuid и numeric с typmod до 38 цифр одинаковы в обоих форматах: значения на ширине типа, numeric как scaled int64 или int128. Validity bitmap общий для обоих форматов и имеет ту же полярность и порядок бит, что NULL bitmap heap-кортежа.
Scaled numeric
Scaled numeric заимствован из openGauss только как идея, но написан с нуля под правила numeric в PostgreSQL. Он даёт основной выигрыш на TPC-H Q1: с varlena-раскладкой Q1 ускорился на 2–9%, а со scaled numeric — в 1.8–2.8 раза.
У эпох есть крайний случай: timestamp 294247-01-10 04:00:54.775807, сдвинутый к Unix-эпохе, даёт ровно int64-максимум, который PostgreSQL использует для +infinity. Поэтому батч с таким timestamp сохраняет колонку в эпохе PostgreSQL.
Колоночные структуры из хранилища
Колоночные данные читаются через контракт источника: модуль хранения публикует функции begin, next, rescan, end и estimate через rendezvous-переменную, а скан открывается обычным table_beginscan. Поэтому snapshot, predicate locks, параллельные диапазоны, MVCC, pruning и удаления остаются за access method.
PAX возвращает группы до 131 072 строк, нарезаемые в батчи без копирования, а porc_vec хранит почти Arrow-раскладку. ao_column возвращает блоки до 16 384 строк: колонка фиксированной ширины без NULL становится срезом разжатого буфера, а varlena-колонка — Datums, указывающими в него. Конвертация в in-memory формат определяется настройкой vexec.batch_format.
Arrow IPC через Motions
Когда транслятор предлагает vexec Motion, у которого отправляющий фрагмент имеет векторный узел на вершине, vexec помещает VecMotionSend под него и VecMotionReceive над ним, а сам Motion перестраивает на том же месте через сеттеры gp_core: Redistribute становится Explicit Redistribute по номеру сегмента.
Строка такого Motion — это колонки фрагмента (все NULL), номер сегмента и кадр как bytea. Кадр состоит из 16 байт собственного формата vexec (VXF1, зарезервированное поле, хеш схемы) и следующего за ним сообщения Arrow IPC от кодека V7_0. В arrow-формате буферы уходят как есть; в postgres-формате varlena пишется целиком, а Datum получателя указывает прямо в кадр. Схема отправляется каждому получателю один раз и хранится там по её хешу, поскольку кадры всех отправителей приходят вперемешку.
В плане тестового кластера на трёх сегментах Vec Motion Receive стоит над Gather Motion 3:1 (slice1; segments: 3), а Vec Motion Send — под ним, с пометкой Frames To: the one gathering. Сортирующий Gather, Motion записи и Motion над строковым узлом кадрами не обрамляются.
Вставка через VecInsert
VecInsert занимает место цикла ModifyTable: он пишет батч в sink хранилища, а где его нет — через table_multi_insert по 1000 строк за раз, как COPY. Проверка NOT NULL выполняется по validity, и первая строка, не прошедшая проверку, отправляется в ExecConstraints(), чтобы ошибка была такой же. Партиции выбираются функциями PostgreSQL, записи в индексы создаются через ExecInsertIndexTuples().
На кластере ModifyTable остаётся на координаторе, потому что gp_core диспатчит запись через него, а VecInsert работает под ним на сегментах. Триггеры, внешние ключи, ON CONFLICT, RETURNING и WITH CHECK сохраняют обычный ModifyTable, и EXPLAIN называет причину.
Flight SQL и Arrow на проводе
Arrow используется в двух местах: как формат батчей в памяти БД и как формат передачи по проводу через Flight SQL. На проводе vexec_flight отдаёт Arrow прямо из батчей, но только пока включён векторный исполнитель: акцептор стартует, только если задан vexec_flight.listen_addresses и загружен vexec. При vexec.mode = off оператор получает FAILED_PRECONDITION, и у расширения вообще нет строкового пути к клиенту. Клиент по Flight SQL получает Arrow record batches — тот самый layout, в котором эти библиотеки и так хранят данные, а схема с типами приходит до первой строки.
Кластерные тесты проходят и over tcp, и under shm: все 2,777 отправителей и 2,777 получателей прошли через кольца, и после завершения получателя ни маппинг, ни файл в /dev/shm не остаются. На широких строках 1.8 КБ shm оказался быстрее: около 80 мс против 93 по tcp.
Процессная модель и вызовы функций других расширений
Процессная модель остаётся постгресовой: acceptor без потоков просит postmaster запустить динамический фоновый воркер на каждое соединение и передаёт ему сокет через SCM_RIGHTS. Сессия ждёт на сокете и latch, как backend, поэтому pg_cancel_backend() и CancelFlightInfo на ней работают. Векторизация эту модель не меняет: backend остаётся однопоточным и на C, palloc в пределах work_mem, spills в BufFile, ошибки через ereport.
Результат пишет DestReceiver у vexec: если на вершине плана стоит векторный узел, хук ExecutorRun передаёт батчи без единой строки, и колонки, чей layout совпадает с их Arrow-типом, уходят в сокет одним writev прямо из буферов батча. Вставки идут обратным путём: CommandStatementIngest превращается в INSERT INTO t ... SELECT ... FROM vexec.ingest_stream(handle), а VecIngest берёт следующее DoPut-сообщение из сокета только когда оператору нужен следующий батч.
Функции других расширений vexec вызывает через fmgr построчно, внутри векторного узла. kernel packнабор объявлений о том, какие функции и на каких строках можно вычислить раньше, чем это сделал бы PostgreSQL не переписывает функции и не вводит ABI: поверх fmgr и soft errors он объявляет три категории. never raises — функция вызывается на активных строках батча (двенадцать 2-D box-операторов PostGIS и ST_SRID). check — функция вызывается там, где проходит проверка пака, а остальные строки PostgreSQL вычисляет сам со своей ошибкой (расстояния pgvector при равных размерностях, ST_X и ST_Y для точки). prefilter — ответ пака берётся там, где он есть, а нерешённая строка уходит в саму функцию (одиннадцать предикатов PostGIS по их боксам).
Prefilter и soft errors
prefilter берёт ответ у пака там, где он у него есть, а неопределённую строку — мягкую ошибкусигнал «не решено», который функция возвращает вместо исключения от ereturn — отправляет самой функции. Это одиннадцать предикатов PostGIS, определяемых по их боксам. Объявление привязывается расширением, его версией, сигнатурой функции и C-символом, и сбрасывается при ALTER EXTENSION UPDATE. vexec_postgis читает формат геометрии собственным кодом, написанным по gserialized.txt, без единой строки PostGIS.
В ExprState поле escontext — указатель на ErrorSaveContext, используемый для узлов выражений, поддерживающих soft errors: если вызывающая сторона хочет, чтобы ошибки выбрасывались, поле должно быть NULL, а если ошибки выбрасывать не нужно, вызывающая сторона должна установить валидный ErrorSaveContext перед вызовом ExecInitExprRec(). В vexec мягкая ошибка от ereturn — это сигнал «не решено», и prefilter передаёт такую строку на вычисление обычной функции, а не берёт ответ пака.
Внутреннее устройство ExprState и шагов вычисления
Массив ExprState->steps — это плоский список шагов вычисления выражения. Последний шаг всегда EEOP_DONE_RETURN или EEOP_DONE_NO_RETURN, что устраняет необходимость проверять конец массива при итерации. Два завершающих опкода нужны, чтобы интерпретатор знал, куда девать результат последнего шага: EEOP_DONE_RETURNзавершающий шаг, при котором вычисленное значение выражения возвращается наружу как результат применяется, когда выражение возвращает значение напрямую, а EEOP_DONE_NO_RETURNзавершающий шаг, при котором значение наружу не возвращается, а важны только побочные эффекты вычисления — когда целью являются побочные эффекты инициализации выражения (например, для проекции или вычисления значения перехода агрегата). Разделение опкодов позволяет интерпретатору не проверять, ожидается ли результат от последнего шага: при EEOP_DONE_RETURN вычисленное значение отдаётся наружу как результат выражения, при EEOP_DONE_NO_RETURN результат наружу не передаётся, а выполнение завершается сразу после побочных эффектов.
ExecInitExprRec() рекурсивно отображает один узел Expr в шаги, добавляя их через ExprEvalPushStep(); для подвыражений указывается, куда сохранять результат. Некоторые шаги, например булевы выражения, позволяют пропускать вычисление подвыражений: в плоском представлении это прыжок к более позднему шагу, а цель прыжка задаётся целочисленным индексом в массиве steps. Обычно ExecInitExprRec() сначала добавляет шаг-прыжок, затем рекурсивно генерирует шаги для подвыражения, которое может быть пропущено, а потом возвращается и исправляет индекс цели прыжка, используя уже известную длину шагов подвыражения; это делается через списки adjust_jumps в execExpr.c. ExprEvalPushStep при нехватке места перераспределяет весь массив steps, поэтому нельзя хранить указатели внутрь массива во время построения выражения.
Computed-goto dispatch в execExprInterp.c
Диспетчеризация по вычисляемому переходу включается, когда доступны вычисляемые переходы: при определённом HAVE_COMPUTED_GOTO определяется EEO_USE_COMPUTED_GOTO, и тогда EEO_DISPATCH выполняет переход по адресу, сохранённому в поле opcode текущего шага. При инициализации интерпретатора каждый шаг заменяет свой числовой код операции на адрес соответствующей метки, и состояние помечается флагом EEO_FLAG_DIRECT_THREADED. Таблица dispatch_table[] строится из адресов меток &&CASE_EEOP_..., по одному на каждый элемент перечисления ExprEvalOp, и её длина проверяется статическим утверждением на соответствие EEOP_LAST + 1. Макрос EEO_OPCODE возвращает адрес метки по коду операции.
#define EEO_DISPATCH() goto *((void *) op->opcode)
#define EEO_OPCODE(opcode) ((intptr_t) dispatch_table[opcode])Если вычисляемые переходы недоступны, используется обычный switch по коду операции: EEO_SWITCH() разворачивается в starteval: switch ((ExprEvalOp) op->opcode), а EEO_DISPATCH() — в goto starteval. Отдельный символ EEO_USE_COMPUTED_GOTO введён, чтобы можно было легко переключать режим локально в этом файле для разработки и тестирования.
JIT-компиляция выражений
llvm_compile_expr создаёт LLVM-функцию с именем через llvm_expand_funcname(context, "evalexpr") и типом llvm_pg_var_func_type("ExecInterpExprStillValid"), строит базовый блок entry и извлекает параметры v_state, v_econtext, v_isnullp. Для каждого шага выражения заранее создаётся блок opblocks[opno], и из entry выполняется переход к первому блоку.
Для вызова функций используется BuildV1Call(context, b, mod, fcinfo, &v_fcinfo_isnull), возвращающий значение, которое сохраняется в v_resvaluep, а признак NULL — в v_resnullp. Для strict-функций перед вызовом строятся блоки проверки каждого аргумента: v_argisnull = l_funcnull(b, v_fcinfo, argno), и при равенстве единице управление уходит на opblocks[opno + 1] (пропуск вызова), иначе — к следующей проверке или к блоку b_nonull. В блоке b_nonull сначала устанавливается resnull в true, затем вызывается BuildV1Call, и результат сохраняется в v_resvaluep и v_fcinfo_isnull в v_resnullp. Для не-strict шагов (например, EEOP_FUNCEXPR_FUSAGE, EEOP_FUNCEXPR_STRICT_FUSAGE, EEOP_MINMAX, EEOP_FIELDSELECT) используется макрос build_EvalXFunc, который через build_EvalXFuncInt передаёт имя функции, v_state, op и массив аргументов с их количеством.
Агрегатные переходы под JIT
Для EEOP_AGG_PLAIN_PERGROUP_NULLCHECK JIT загружает aggstate->all_pergroups по смещению setoff и, если полученный указатель равен нулю, переходит на блок jumpnull. Для опкодов EEOP_AGG_PLAIN_TRANS_* JIT вычисляет pergroup как элемент all_pergroups[setoff][transno], а для INIT-вариантов проверяет поле notransvalue и при необходимости вызывает ExecAggInitGroup. Затем transvalue и transvalueIsNull из pergroup загружаются в fcinfo->args[0] и fcinfo->args[0].isnull, вызывается transition-функция через BuildV1Call, и результат сохраняется обратно в transvalue/transvalueIsNull. Для BYREF-вариантов, если возвращённый Datum не равен старому transvalue, вызывается ExecAggCopyTransValue для копирования в aggcontext. Поле wfuncno в JIT не устанавливается: оно ещё не задано и загружается из памяти каждый раз при обработке оконной функции.
Как сравнивали и что получилось
Все цифры получены на Docker-образах, собранных из закоммиченных веток, на 16-ядерном Ryzen 9 9955HX3D с 60 ГБ памяти, без assertions, и каждый ответ сверялся с DuckDB. Ограничения ресурсов и конфигурация железа в остальном не указаны.
Планирование. EXPLAIN всех 121 TPC-запросов на четырёх сегментах под ORCA занял 9,715 ms без vexec и 9,696 ms с ним. Под планировщиком PostgreSQL то же самое заняло 161 и 166 ms.
TPC-H Q1 на SF1 — медиана пяти запусков от execute до таблицы Arrow в клиенте:
| Вариант | Значения | |
|---|---|---|
| PostgreSQL planner + vexec, auto | 506 и 311 | |
| PostgreSQL planner + vexec, [[term:force | Режим vexec.mode, в котором векторные пути добавляются через хуки планировщика и конкурируют с обычными построчными путями в add_path по стоимости.]] | 498 и 365 |
| ORCA | 1,647 и 924 | |
| ORCA + vexec, auto | 1,349 и 745 |
vexec снижает время на 19–24% для планировщика PostgreSQL в auto-режиме и на 16–19% для ORCA. Лучшие запросы ускоряются в 8.8–9.5×: Q15 и Q16 под планировщиком PostgreSQL, Q29 под ORCA. Под планировщиком PostgreSQL запросы Q10, Q11 и Q13 с COUNT(DISTINCT) замедлились; под ORCA — Q25 и Q39, где сканирование возвращает текстовые столбцы. ORCA отстаёт от планировщика PostgreSQL в 2.3–2.6×, прежде всего из-за последовательных планов.
Flight SQL измерялся на TPC-H Q1 при SF1 как медиана пяти запусков, отдельно на vanilla PostgreSQL 19 и на координаторе порта с двумя сегментами. На порте измерение было сделано до V7, и результат всё ещё проходил через Gather Motion как строки.
Вставка измерялась на 200 000 строк lineitem как медиана трёх загрузок, в строках в секунду.
ClickBench запускался по собственному протоколу на vanilla PostgreSQL 19 с ORCA, но на 10 миллионах строк, поэтому эти цифры нельзя сравнивать с опубликованными результатами на 100 миллионах. ORCA на vanilla REL_19_STABLE собирается так, что все 239 regression-тестов проходят, 27 из них с рассмотренными различиями.
Первое измерение TPC на четырёх сегментах после V2, когда векторизованы были только сканы и агрегация, в среднем дало около 1×, а его повторное измерение ждёт свободного хоста.
Неожиданности при реализации
С V1 тип boolean register жил в памяти statement, и следующий statement читал освобождённую память. В одной строке запроса проверка проходила, потому что идентичный тип занимал ту же память, но отдельными запросами бэкенд падал каждый раз. Поэтому теперь каждый regression check сначала запускается без своего фикса.
Unknown geometry type: 2139062143 — это 0x7F7F7F7F, освобождённая память debug-сборки: vexec вычислял ленивую цель в памяти запроса, то есть в fn_mcxt функций, а кэши PostGIS считали эту память своей. Это поймал собственный тест PostGIS.
ORCA отбрасывал лучший план: он использует частичный план как нижнюю границу для pruning, а CCostModelVec оценил его выше планов, которые он ограничивает, поэтому ORCA выбросил более дешёвые агрегации и порядки соединений.
OID 7100: ORCA жёстко кодирует хеш-OID Cloudberry как константы, тогда как gp_core назначает их при установке. Поэтому GROUP BY по таблице с ключом cdbhash_int4_ops падал с could not find hash function for type 23 in operator family 7100. Автор столкнулся с этим при реализации передачи данных Arrow через Motions.
Объём кода
Около 43 000 строк C в vexec, 7 900 строк в vexec_flight, тысяча строк в kernel packs и примерно 13 600 строк в модулях порта Cloudberry в pg19/, включая shm-транспорт и копии файлов PAX.
Что из этого следует на практике
Векторный исполнитель можно встроить в PostgreSQL без форка ядра: хуки планировщика дают векторным путям конкурировать с построчными по стоимости, а без расширения сервер остаётся ванильным. Там, где хуки не достают — в планах ORCA, — нужен отдельный API регистрации oracle, оценок стоимости и построителей узлов, и замену узлов приходится делать до проверки Motions, создания slice table и проходов M8, чтобы последующие шаги видели финальное дерево.
Выбор in-memory формата — это выбор того, за какие границы платить: postgres не платит за границы PostgreSQL, arrow — за свои, а конвертации происходят только на именованных границах и учитываются в стоимости. Основной выигрыш на агрегации даёт не раскладка колонок сама по себе, а scaled numeric: с varlena-раскладкой Q1 ускорился на 2–9%, со scaled numeric — в 1.8–2.8 раза.
Векторизация не отменяет постгресовую процессную модель: backend остаётся однопоточным, сессия ждёт на сокете и latch, поэтому отмена запроса работает как обычно. Функции других расширений вызываются через fmgr построчно, а kernel pack лишь объявляет, какие функции и на каких строках можно вычислить раньше, — без переписывания функций и без нового ABI.
Стоимость векторного пути выводится из построчного с множителями, поэтому векторные узлы не вытесняют построчные автоматически, а конкурируют с ними. Отсюда и режимы: force заставляет конкурировать, auto выбирает по стоимости, explain оценивает векторные альтернативы, но не выбирает их, и объясняет, что рассматривалось и почему не было взято.