На прошлой неделе я перенес форк Apache Cloudberry на PostgreSQL 19 в виде набора расширений и рассказал, где этот порт проигрывает оригинальному форку. Впрочем как и Cloudberry на ClickBench современные колоночные движки обгоняют его на порядок. И дело тут не в реализации MPP, а в исполнителе PostgreSQL - он работает с кортежами в памяти СУБД. Каждый кортеж проходит отдельно, значение проходит через fmgr, а сегменты кластера лишь добавляют число процессоров на которых крутится тот же самый цикл по строкам таблицы.
Следующий логичный шаг для ускорения - векторизованный исполнитель в PostgreSQL. В открытом коде Apache Cloudberry его нет: от закрытого движка в репозитарии остались только следы - флаг create_vectorization_plan, который планировщик всегда передает как false, а также узел WindowHashAgg без реализации и адаптер PAX под VEC_BUILD, который не компилируется. Проектировать придется самому и, конечно, снова в виде расширения постгреса.
Так появился pg_vexec - набор расширений для PostgreSQL 19:
vexec - векторный планировщик и исполнитель, реализованные в одном модуле;
vexec_flight - endpoint Arrow Flight SQL, который работает для векторного исполнителя, сконфигурированного для работы на Arrow буферах, и позволяет клиентам ADBC подключаться к PostgreSQL по родному протоколу;
vexec_pgvector и vexec_postgis - пакеты ядер для нового исполнителя, с которыми функции одноименных расширений работают на колоночных структурах данных в памяти.
Проект не требует модификации ядра PostgreSQL даже при использовании планировщика ORCA в одноузловой конфигурации без координатора на ванильной сборке PostgreSQL. Модуль gp_orca из порта Cloudberry теперь собирается без gp_core и прочих потрохов greenplum и патчей ядра СУБД, а vexec превращает ORCA в векторный планировщик с исполнителем в “одном флаконе”: его оптимизатор оценивает стоимости теперь и векторных узлов, а транслятор генерирует эти узлы в плане. Патчи ядра не нужны, только если не нужна функциональность и колоночное хранение данных таблиц из Cloudberry. Если же загрузить расширение Cloudberry на пропатченном ядре постгреса, то vexec может более эффективно читать и писать колоночные PAX и ao_column, без лишних конвертаций из и в строки, а Motion тогда может передавать между сегментами данные сразу в колоночном формате Arrow IPC.
И к постгресу можно подключаться как по привычному pgwire протоколу, так и по более современному Flight SQL, что могут оценить те кто будет запускать ML модели на данных из PostgreSQL. Как минимум при загрузке данных в polars скажут мне спасибо!
Код был написан LLM Opus 5.5 в параллельных сессиях, каждая в своем git worktree, а я ставил задачи, принимал архитектурные решения. Работу над планом векторного расширения PG начал 2 октября, а код начал писаться 5 октября, и к 7 октября пройдены тринадцать итераций, от первого запуска ClickBench для бейзлайна до реализации передачи колоночных данных в Arrow из памяти СУБД между серверами через существующий механизм Motion.
Сначала был DataFusion
Начал я эту затею полтора месяца назад с идеи встроить в PostgreSQL готовый векторный движок Apache DataFusion. До меня это уже делали в открытых проектах, но результат меня не устраивал и хотел сделать новое решение распределенным и работающим быстрее того что было сделано до этого. В плане для pg_arrow было ядро на Rust которое взаимодействовало с PostgreSQL через его C ABI, а все что касается PostgreSQL оставалось бы в тонкой прослойке на C. DataFusion жил бы в одном фоновом процессе на кластер, со своим пулом потоков Tokio. Его бэкенд предлагал бы планировщику CustomPath-ы ‘ArrowScan’ через path-хуки, а выбранное поддерево переводил бы из ‘PlannerInfo’ в логический план DataFusion и отправлял сервису. Строки из heap процессы СУБД кодировали в страницы Arrow в общей памяти, а результат возвращался бы страницами в лейауте PostgreSQL. Arrow клиентам раздавал FlightSQL прямо из сервиса, а колоночные таблицы хранились в Iceberg/Parquet в объектном S3 совместимом хранилище.
Чем подробнее я прорабатывал этот план с LLM агентами, тем длиннее становился список компромиссов. Семантика DataFusion - это не PostgreSQL с его collation, numeric scale, NaN, часовые пояса и даже ‘now()’ отличалось бы от требуемого поведения. JSONB и геотипы стали бы текстом и EWKB сериализацией соответственно, собираясь заново для каждой строки, а каждый путь данных плодил бы лишние копии данных. Сервис должен быть дочерним процессом postmaster и потенциальный segfault из zstd или сработавший OOM killer перезапускал бы весь кластер СУБД. DataFusion 55.1 не умеет сбрасывать на диск Hash join, datafusion-proto молча терял бы поля плана, а пока SubPlan/InitPlan/CTE/партиции/eager aggregation не переведены, запрос оставался бы на плане постгреса. Слишком много что нужно доделывать в этом движке и в чем семантика не сходится.
Выигрыш при этом был под вопросом пока не реализуешь эту связку между разнородными технологиями. А самое быстрое в таком движке - векторные сканы, фильтры и агрегаты - можно получить внутри бэкенда с семантикой СУБД и без межпроцессных копий данных. Так pg_arrow и остался планом без реализации в коде, а когда получилось портировать Cloudberry в виде расширения для PostgreSQL 19, то он мне дал доступ к внутренностям ORCA на последнем ядре базы данных, PAX и gp_ao в виде модулей, у меня появился план по векторизации выполнения запросов. Но не в очередном движке рядом с PG а в виде векторизованного исполнителя внутри СУБД. От pg_arrow в нем остались таблица соответствия типов Arrow<->PG, лейаут данных от PostgreSQL, корпус семантики функций, архитектура подключения FlightSQL к серверу БД, а также требование что TPC-H Q1 через него должен быть быстрее бинарного COPY.
Как и в рефакторинге легаси систем на своих прошлых работах, я решил разгрести авгиевы конюшни со стороны главной зависимости проекта - планировщика запросов. Взял ORCA и стал готовить PostgreSQL для реализации идеи с векторизацией выполнения и передачи данных через FlightSQL в дополнение к pgwire.
Почему расширение
Векторизация PostgreSQL идея не новая. У каждого пути реализации этого была своя цена. openGauss встроил векторизованный исполнитель примерно на 82 тысячи строк в форк PostgreSQL 9.2, который с апстримом уже не сольешь. pg_duckdb передает запрос второму движку DuckDB в бэкенде и вопросы к семантике у него те же, что были у моего плана pg_arrow. Проекты openGauss, vectorize_engine, Hydra и VectorAgg из TimescaleDB к тому же переписывают уже готовый план и векторизация не соревнуется по стоимости в самом планировщике. У TimescaleDB такой подход давал неверные результаты и падения, например Wrong result in vector agg & columnar index scan.
Агентам я ставил почти те же условия, что и при портировании Apache Cloudberry: “подключать исполнитель только через существующие хуки PostgreSQL, а без загруженного расширения сервер должен себя вести как ванильный”. Получил от них шесть исследовательских отчетов проверил по коду каждую часть дизайна, от планирования до работы кластера, и новый хук в ядре постгреса не понадобился ни одной:
без изменения ядра: в целом мне повезло! Вендоры, которые делали векторизованные исполнители для PostgreSQL, видимо добавили все необходимые для этого точки расширения/хуки в апстрим ядра PostgreSQL, кроме чтения/записи в колоночном формате в TableAM. В ядре СУБД уже были все необходимые точки для расширения;
узлы векторного исполнения доступны планировщику и соревнуются с исполнениями по кортежам по стоимости. Готовый план никто не переписывает;
одна модель на оба планировщика: один оракул, одна модель стоимости в единицах PostgreSQL, один генератор узлов в плане;
семантика PostgreSQL совпадающая до последнего SQLSTATE: невычисленные ветви CASE, collation, NaN, порядок суммирования float, векторный kernel только ускоряет но не меняет результат, полнота обеспечивается fallback на вычислитель выражений PG;
исходники Cloudberry (кроме ‘pg19/’) не меняются и код из других векторных движков не копируется;
бэкенд остается однопоточным на C: palloc в пределах ‘work_mem’, спилл в BufFile, ошибки через ‘ereport’.
Ванильность расширения проверяется на каждом этапе разработки, так же как при портировании Cloudberry. 239 регрессионных тестов PostgreSQL проходят на неизменных ожидаемых результатах, когда vexec установлен и предзагружен в режиме ‘off’. Также дифференциальный runner сравнивает с ‘off’ ответы сессий ‘force’ postgres, ‘force’ arrow. И все расхождения, вроде планов запросов, которые печатают сами тесты, должны быть разобраны и записаны. Также прогоны на тестах из Cloudberry сравниваются в режимах ‘force’ и ‘off’, а 121 запрос TPC-H и TPC-DS сверяется с ответами DuckDB на корректность. Если же план с векторным узлом приходит на сегмент, где библиотека не загружена, тот ответит чистой ошибкой ExtensibleNodeMethods "VecScan" was not registered
Архитектурные особенности и чем vexec лучше просто Cloudberry и PostgreSQL
PostgreSQL и Cloudberry исполняют запрос одинаково, кортеж за кортежем, vexec же меняет структуры данных в памяти на буфер 1024 строк, в колоночном представлении. Столько строк фрагмента помещается в кеши L1/L2 CPU, пока их фильтруют, проецируют и агрегируют. Но интереснее не сама колоночная структура данных в памяти, а то, где планируются векторные запросы по этим данным и как данные попадают в эту структуру и покидают ее.
PostgreSQL 19 | Cloudberry без vexec | pg_vexec на ванильном PostgreSQL 19 | pg_vexec в порте Cloudberry | |
|---|---|---|---|---|
Единица исполнения | строка | строка: векторного исполнителя в открытом коде нет | батч до 1024 строк по колонкам; ядра по OID функции, fallback внутри того же узла | то же на координаторе и каждом сегменте |
Кто решает, что векторизовать | - | судя по тестам PAX, закрытый движок переводит готовый план узел в узел, со стоимостями строкового | оба планировщика, по стоимости: path-хуки PostgreSQL и ORCA | ORCA со стоимостью векторных узлов для MPP-планов, планировщик PostgreSQL на сегментах |
Чтение таблиц | heap через слоты | AOCO и PAX отдают слоты | heap постранично, с пакетной проверкой MVCC; другие AM через слоты | PAX и ao_column отдают батчи; агрегаты из статистики PAX |
Вставка | ModifyTable построчно; | то же; PAX и gp_ao сами раскладывают строки по колонкам | VecInsert, в heap по 1000 строк через | VecInsert пишет колонки в PAX и ao_column без строк |
Между процессами | Gather: MinimalTuple на строку | Motion: MinimalTuple на строку и fmgr-хеш ключа на строку | как в PostgreSQL | кадры Arrow IPC, векторный cdbhash, общая память для сегментов одного хоста |
Arrow клиентам | нет: ADBC собирает Arrow из бинарного COPY | нет | Flight SQL: выдача из батчей, вставка в VecInsert | то же на координаторе |
Функции PostGIS и pgvector | построчно | построчно | батч строк за вызов через fmgr по декларациям пакетов | то же на сегментах |
Изменения ядра | - | форк; в порте 24 патча-хука | нет | только те, что нужны самому порту |
Дальше по порядку, начиная с того, из каких компонентов все собрано.
Компоненты
vexec - модуль из ‘shared_preload_libraries’, который собирается через PGXS для ванильного PostgreSQL 19, и с портом расширения Cloudberry. Векторный планировщик (‘plan/’) встроен и решает какие операторы станут векторными узлами. Исполнитель - это CustomScan-узлы (‘exec/’) поверх слоя батчей (‘batch/’) и компилятора выражений (‘expr/’). Рядом живут кодек ArrowIPC (‘ipc/’), egress API для FlightSQL (‘egress/’) и кадры для Motion (‘motion/’).

Внешние связи vexec идут через переменные rendezvous из PostgreSQL с мажорной версией в имени, также модули обнаруживают API gp_core. Хранилища регистрируют читателей батчей в ‘vexec/source_v1’ а приемники данных в ‘vexec/sink_v1’. Компонент vexec_flight находит ‘vexec/egress_v1’, пакеты ядер ‘vexec/kernels_v1’, а сам vexec находит ORCA через ‘Cloudberry/gp_orca_vec_v1’. Порядок модулей в ‘shared_preload_libraries’ не важен. Без загруженной библиотеки регистрация не происходит и модуль не доступен.
Векторный планировщик внутри СУБД, а не рядом с ней
В планировщике PostgreSQL vexec добавляет пути через его же хуки: ‘VecScan’ - в ‘set_rel_pathlist_hook’, как колоночный Citus свой скан, ‘VecHashJoin’ - в ‘set_join_pathlist_hook’, ‘VecAgg’ и ‘VecSort’ - в ‘create_upper_paths_hook’. Дальше они соревнуются со строчными узлами планов в обычном ‘add_path’. Стоимость векторного пути считается из пути для кортежа с множителями на работу ядер, колоночный источник оценивает только байты нужных колонок, а выбор между строками и батчами определяется на основе стоимости. Маски стратегий планировщика используются также, так что ‘enable_seqscan=off’ и подсказки pg_hint_plan действуют и на векторные пути. Для частичной агрегации PostgreSQL хук не вызывает, поэтому пару для частичного и финального VecAgg вокруг Gather vexec строит сам:
SET vexec.mode = force; EXPLAIN (COSTS OFF) SELECT hw.k, count(*), sum(hp.b) FROM hp JOIN hw ON hp.id = hw.id GROUP BY hw.k;
Vec Finalize HashAggregate Group Key: k -> Gather Workers Planned: 3 -> Vec Partial HashAggregate Group Key: k -> Vec Hash Join Hash Cond: (hp.id = hw.id) -> Parallel Vec Seq Scan on hp -> Vec Seq Scan on hw
До планов ORCA path-хуки не достают, она строит собственный ‘PlannerInfo’ и возвращает готовый план. Поэтому порт использует небольшой API из gp_orca, через который vexec регистрирует свой оракул, стоимостную оценку и генератор узлов плана:
CCostModelVec, подклассCCostModelGPDB, считает формулы ORCA дважды, как есть и с векторными множителями, и смешивает их. ORCA выбирает порядок соединений, стадии агрегации и Motion уже с векторным исполнением в стоимости;транслятор предлагает vexec каждый готовый узел и тот заменяет его векторным, если оракул согласен, еще до проверки Motion, таблицы слайсов, поэтому все последующие шаги Cloudberry видят окончательное дерево;
хешированное оконное агрегирование ORCA, которое в PostgreSQL 19 не на чем исполнить становится ‘VecWindowHashAgg’ под обычным ‘WindowAgg’.
Режим ‘vexec.mode = explain’ считает векторные альтернативы, но не выбирает их, а ‘EXPLAIN (VEXEC)’ объясняет что рассмотрено и почему не взято:
SET vexec.mode = explain; EXPLAIN (VEXEC, COSTS OFF) SELECT b, count(*), sum(d) FROM vt WHERE a > 10 GROUP BY b ORDER BY b;
Sort Sort Key: b -> HashAggregate Group Key: b -> Seq Scan on vt Filter: (a > 10) Vexec: mode explain, format postgres VecScan on vt: not chosen (explain mode) source: heap's pages; quals: 1 kernel step, 0 fallback steps; target: 0 kernel steps, 0 fallback steps VecAgg on GROUP BY: not chosen (explain mode) hashed, 1 grouping column, 2 aggregates, 2 with vector transitions (count(*), numeric sum) VecSort on ORDER BY: not chosen (explain mode) 1 sort key
Планирование при этом не становится дороже: EXPLAIN всех 121 запроса TPC на четырех сегментах при работе под планировщиком ORCA занял 9715мс без vexec и 9696мс с ним, а при работе под планировщиком PostgreSQL - 161 и 166мс.
В фазе разработки V6 реализовал ORCA на ванильном PostgreSQL 19. Сборка с параметром ‘-Dorca_single_node’ заменяет gp_core заглушкой примерно на 400строк: она отвечает на вызов его API и на 14 его функций, которые ORCA вызывает на одном узле, так, как ответил бы gp_core на сервере без сегментов. С ORCA на ванильном REL_19_STABLE из репозитария postgresql проходят все 239 регрессионных тестов, 27 из них через разобранные различия, а 121 запрос TPC в режиме vexec force отвечает так же как эталон DuckDB. Из 652 hash join в их планах стали узлы ‘VecHashJoin’ в 651, кроме одного с full join который VecHashJoin пока не умеет читать.
Два формата структур данных в памяти
Формат батча задается настройкой, это было мое требование. ‘vexec.batch_format = postgres’ это значение по умолчанию, хранит значения колонок так как их определяет PostgreSQL, для значения параметра ‘arrow’ - в стандартных типах для Arrow:
Тип | Формат postgres | Формат arrow |
|---|---|---|
bool | байт на значение | бит на значение |
date, timestamp | эпоха PostgreSQL, 2000-01-01 | эпоха Unix |
interval | 16 байт PostgreSQL |
|
text, bytea | Datum на значение, с заголовком varlena |
|
int, float, uuid, numeric с typmod до 38 цифр | одинаково в обоих форматах: значения по ширине типа, numeric как масштабированные int64 или int128 |
Формату postgres бесплатны границы самого PostgreSQL: fmgr, слоты, tuplesort, хеш-функции, хранилище типа heap, а также идентичные по типам ao_column и porc. Формату arrow ничего не стоят границы где родной Arrow: экспорт, FlightSQL, porc_vec и кадры ArrowIPC Motion между процессами (а на одном хосте даже без копирования, в случае межпроцессного обмена по разделяемой памяти). Конверсии типов случаются только на границах и стоимостная модель учитывает их цену для планировщика. Битовая карта validity общая для двух форматов и у нее тот же порядок бит что и у карты NULL в кортежах heap. Ядра функций генерируются под каждую из раскладок, а четыре настройки меняют раскладку отдельно для строк, bool, дат и numeric. На ClickBench форматы разошлись на 2.6% между собой по производительности.
Масштабированный numeric подсмотрен у openGauss, но написан заново по правилам numeric PostgreSQL и выигрыш в TPC-H Q1 почти целиком его: в лейауте varlena Q1 ускорялся на 2-9% а с масштабированным numeric в 1.8-2.8 раза. У эпохи есть граница: ‘294247-01-10 04:00:54.775807’ после сдвига к эпохе Unix дает ровно максимальное значение int64, которым PostgreSQL обозначает +infinity. Поэтому батч с таким временем хранит колонку в эпохе PostgreSQL и это нюанс размерности.
Колоночные структуры данных из хранилища
Контракт источников отвечает “зачем нужны новые хуки в TableAM”. Хранилище публикует через rendezvous функции ‘begin’, ‘next’, ‘rescan’, ‘end’ и ‘estimate’, а скан открывается обычным table_beginscan, так что снимок, предикатные блокировки, параллельные участки, MVCC и удаления остаются за методом доступа. Удаленная строка попадает в карту отбора а не в validity, иначе ‘count(*)’ посчитал бы ее.
heap vexec читает сам страницу за страницей через ‘heap_prepare_pagescan’, как и TABLESAMPLE: чистка страницы, блокировка, пакетная проверка видимости ‘HeapTupleSatisfiesMVCCBatch’ из PostgreSQL 19 и разбор нужных колонок сразу в структуры батча в памяти. Видимость vexec своим кодом не определяет, а в Cloudberry автомагически получает видимость распределенного снимка транзакции.
ao_column отдает блоки до 16384 строк: колонка фиксированной ширины без NULL становится срезом распакованного буфера, varlena - Datum внутри него.
PAX отдает группы до 131072 строк, нарезанные на батчи без копирования, а porc_vec хранит раскладку Arrow. Значения для ‘aggregate()’: ‘count’, ‘min’, ‘max’, ‘sum’ и ‘avg’ без условий берутся прямо из статистики файлов и групп, и ‘count(*)’ по 10млн строк занимает 0.33мс вместо 265мс. А через ‘set_keys()’ ‘VecSort’ с LIMIT передает скану рамку “бегущей границы” и PAX пропускает группы, которые в ответ уже не попадут. Мне это напомнило как использует статистику PG-Strom читая неизменяемые Arrow, точнее их метаданные на диске, сюда бы еще bloom фильтр добавить в будущем!
Вставка устроена зеркально. ‘VecInsert’ встает на место цикла ModifyTable и записывает данные из батча в приемник хранилища, а если механизм не доступен, то через ‘table_multi_insert’ по 1000 строк как это делает COPY. NOT NULL проверяется по validity, а первая не прошедшая проверку строка уходит в ‘ExecConstraints()’ чтобы ошибка была идентична ванильному постгресу. Секции выбираются функциями PostgreSQL, индексы получают записи через ‘ExecInsertIndexTuples()’ и ядро для этого трогать не пришлось. COPY FROM тоже вставляет без ModifyTable. В кластерном деплойменте ModifyTable остается на координаторе, потому что gp_core рассылает запись по нему, а VecInsert работает под ним уже на сегментах. Триггеры, внешние ключи, ‘ON CONFLICT’, ‘RETURNING’ и ‘WITH CHECK’ оставляют обычный ModifyTable а EXPLAIN называет причину.

Все, что vexec потребовал при реализации своего порта Cloudberry - код модулей в ‘pg19/’: API gp_orca и ‘CCostModelVec’, входы ‘GpCoreApi’ 1.15, имена ‘vexec.*’ в списке настроек, которые координатор рассылает сегментам, читатели и приемники данных PAX и gp_ao, транспорт на одном хосте shm. Четыре библиотеки ядра ORCA компилируются из дерева Cloudberry без изменений. Патчи ядра порта vexec не вызывает: он может ими пользоваться косвенно, но только теми, которыми пользуются PAX и gp_ao, и с хуком памяти, который учитывает его батчи в группах ресурсов, как память любого модуля.
Arrow IPC в Motion
Между сегментами Cloudberry передает кортежи. Для каждой строки Motion формирует MinimalTuple и вызывает хеш функцию ключа через fmgr, а на координаторе строки идут через libpq с send и receive на каждое значение. Если по обе стороны в Motion стоят векторные узлы, батч разбирается на строки только затем, чтобы собраться обратно.
Я спросил, нельзя ли читать ключи распределения прямо из буферов Arrow и передавать батчи между узлами по Arrow Flight SQL. Первое в итоге реализовал, а во втором смысла и особого преимущества перед текущим Motion механизмом особо не было. Interconnect порта Cloudberry уже все нужное для этого имеет, просто сменил содержимое на Arrow IPC, при этом код самого Motion остался тем же. Плюс добавил передачу Motion по кольцевому буферу через разделяемую память между процессами вместо tcp.
Когда транслятор предлагает vexec Motion, у которого вершина отправляющего фрагмента является векторным узлом, то vexec ставит под ним ‘VecMotionSend’, над ним ‘VecMotionReceive’, а сам Motion пересобирает на том же месте через сеттеры gp_core: Redistribute становится Explicit Redistribute по номеру сегмента. Строка такого Motion - колонки фрагмента, все NULL, номер сегмента и фрейм в bytea;
Фрейм - 16 байт vexec (‘VXF1’, резерв, хеш схемы) и сообщение Arrow IPC из кодека. В формате структур данных arrow буферы уходят как есть, а в формате postgres varlena пишется целиком и Datum получателя указывают прямо во фрейм. Схема данных уходит каждому получателю один раз и хранится там по хешу, ведь кадры всех отправителей приходят вперемешку;
Векторный cdbhash повторяет ‘GpHashSegment’ бит в бит, иначе строки ушли бы не в те сегменты: ядра для целых, float, текста, uuid и дат, ‘hash_numeric’ и построчный путь через gp_core для legacy ключей;
‘VecHashJoin’ которому больше не нужна сторона с Motion, останавливает ее отправителей через ‘squelch_subtree()’, а счетчики векторных узлов приходят с сегментов в EXPLAIN ANALYZE;
На координатор фреймы идут тем же бинарным курсором libpq.
Пару слов про локальный транспорт между сегментами на одном хосте ‘shm’. Отправитель создает анонимный файл через ‘memfd_create’, передает дескриптор получателю через ‘SCM_RIGHTS’ после токена оператора и пишет фреймы в кольцевой буфер, откуда их потом получатель читает. И получается что запись в кольцо - единственная копия данных на этом пути. И получатель и отправитель засыпают на ‘eventfd’ в своем ‘WaitEventSet’, и отмена с таймаутом работает так же как на tcp, а безымянное отображение памяти освобождается вместе с последним процессом, даже упавшим. Между узлами кластера слайс идет по tcp как и раньше.
Оба параметра-переключателя начинают действовать со следующего оператора: ‘vexec.enable_motion_frames’ читается при планировании и смене значения вызывает ‘ResetPlanCache()’ так что prepared statement и PL/pgSQL перепланируются, а транспорт учитывает параметр ‘gp.interconnect_type’. Вот пример плана с тестового кластера из трех сегментов:
Vec Finalize Aggregate -> Vec Motion Receive -> Gather Motion 3:1 (slice1; segments: 3) -> Vec Motion Send Frames To: the one gathering -> Vec Partial Aggregate -> Vec Hash Join Hash Cond: (h.n2 = x.n) -> Vec Motion Receive -> Explicit Redistribute Motion 3:3 (slice2; segments: 3) -> Vec Motion Send Frames To: the segments their keys hash to Hash Key: n2 -> Vec Seq Scan on lg_h h -> Vec Seq Scan on lg_n x Optimizer: GPORCA
Тесты кластера проходят и на tcp и на shm, где все 2777 отправителей и 2777 получателей обменивались через кольцевой буфер, а после терминации получателя не остается ни отображения региона памяти, ни файлы в ‘/dev/shm’. На широких строках по 1.8КБ shm получился быстрее tcp - около 80мс сравнивая с 93мс на межпроцессный обмен по tcp.
Arrow как внутри СУБД, так и в wire протоколе Flight SQL
Arrow клиенты сегодня читают PostgreSQL через ADBC и двоичный COPY: сервер отдает строки, а драйвер собирает из них колоночные структуры на клиенте. vexec_flight отдает Arrow прямо из батча и только когда активен векторный исполнитель. Acceptor стартует лишь если задан ‘vexec_flight.listen_addresses’ и загружен vexec. При ‘vexec.mode = off’ оператор получает ‘FAILED_PRECONDITION’.
Flight SQL для BI и ML это не просто другой транспорт. По pgwire результат идет строками: каждое значение проходит через функцию send своего типа или превращается в текст, драйвер вроде psycopg или JDBC собирает строки, а pandas, Polars или DuckDB потом еще раз раскладывают их по колонкам. По Flight SQL клиент получает record batch Arrow, то есть ту раскладку, в которой эти библиотеки и так держат данные: колонку фиксированной ширины без NULL pyarrow отдает массивом NumPy без копирования. Схема с типами приходит до первой строки, decimal - с точностью и масштабом, timestamptz - как время в UTC, так что BI-инструменту не нужно угадывать типы по тексту. Драйверы стандартные: ADBC Flight SQL для Python, Go, Java и C и Flight SQL JDBC для всего, что принимает JDBC-драйвер. Обратный путь тоже колоночный: adbc_ingest() записывает DataFrame прямо в таблицу через VecInsert. Миллион строк lineitem Flight отдал в 4,7 раза быстрее бинарного COPY. Flight SQL лишь дополняет, не заменяет, привычный клиентам PostgreSQL протокол pgwire.

Модель процессов здесь та же что и у PostgreSQL: acceptor без потоков просит postmaster запустить фоновый процесс на каждое соединение и передает ему сокет через ‘SCM_RIGHTS’. Сессия сама держит TLS, HTTP/2 на nghttp2 и gRPC и ждет на сокете и своем latch поэтому на нее действуют ‘pg_cancel_backend()’ и ‘CancelFlightInfo’. Логин проверяется по ‘pg_hba.conf’ сверяя с реальным адресом клиента, паролю роли и лимитам соединений, а операторы идут через обычный путь, так что хуки, права, транзакции и ‘pg_stat_statements’ ведут себя так же как и по обычному протоколу постгреса.
Результат пишет DestReceiver в vexec. Если наверху плана векторный узел, то хук ‘ExecutorRun’ отдает его батчи без единой строки, а колонки, если лейаут совпадает с типом Arrow, уходят в сокет одним writev прямо из буфера батча в памяти. Медленный клиент просто тормозит сессию - при неспешном чтении 20гигабайтного результата его память сессии выросла меньше чем на мегабайт.
Вставка идет обратным путем. ‘CommandStatementIngest’ превращается в ‘INSERT INTO t … SELECT … FROM vexec.ingest_stream(handle)’ и ‘VecIngest’ забирает очередное сообщение DoPut из сокета, только когда оператору нужен следующий батч. Arrow клиента проверяется как и любой другой ввод пользователя: смещения, UTF-8, диапазоны дат, точность decimal, где типы колонки совпадают с типами из Arrow, буферы сообщения становятся буферами батча, который ‘VecInsert’ отдает приемнику PAX или ao_column.
Функции PostGIS и pgvector
Функции внешних расширений vexec вызывает через fmgr построчно, внутри векторного узла. Но pgvector не помечает свои функции leakproof, а PostGIS помечает только сравнения btree и ‘geometry_hash’, поэтому условие с расстоянием или ‘ST_Intersects’ целиком становилось ленивым. Пакет ядер не переписывает функции и не вводит ABI: поверх fmgr и ошибок PostgreSQL он объявляет, какие функции и на каких строках можно вызвать раньше, чем это сделал бы PostgreSQL:
никогда не бросает ошибку: функция вызывается на активных строках батча - двенадцать 2-D операторов боксов PostGIS и ‘ST_SRID’;
проверка: функция вызывается там, где прошла проверка пакета, а остальные строки PostgreSQL вычисляет сам, со своей ошибкой - расстояния pgvector при равной размерности, ‘ST_X’ и ‘ST_Y’ для точки;
префильтр: ответ пакета берется там, где он есть, а неопределенная строка (мягкая ошибка ‘ereturn’) уходит к самой функции - одиннадцать предикатов PostGIS по их боксам.
Декларация ядра функции привязывается по расширению, его версии, сигнатуре и C-символу функции и сбрасывается при ‘ALTER EXTENSION UPDATE’. ‘vexec_postgis’ читает формат геометрий своим кодом, написанным по ‘gserialized.txt’, без копирования кода PostGIS. Тесты pgvector (14) и базовые тесты PostGIS (143) дают с vexec те же ответы, что и без него, а сервер без пакета считает те же вызовы построчно, медленнее, и идентично.
Текущие ограничения реализации
Унаследованные от PostgreSQL 19:
Gather и Gather Merge остаются построчными: очередь кортежей переносит один MinimalTuple на строку;
‘ExecSetTupleBound’ не доходит до CustomScan, поэтому границу LIMIT ‘VecSort’ получает при планировании;
под UPDATE, DELETE, MERGE, LockRows и курсорами ‘WHERE CURRENT OF’ векторных узлов нет: EvalPlanQual перечитывает строку в слот сканирующего узла, а для heap это должен быть буферный слот.
В сравнении с Cloudberry:
на ванильном PostgreSQL планы ORCA последовательные: ее параллелизм включает настройка ‘gp.enable_parallel’ из gp_core;
сортированный Gather Motion фреймов не переносит, он сливает строки;
у PAX нет ‘index_delete_tuples’, и после прерванной вставки в таблицу с btree следующая может упасть, через ModifyTable точно так же.
Векторные:
‘COUNT(DISTINCT)’ с группировкой по строкам под планировщиком PostgreSQL в режиме auto в 3.1-3.6 раза медленнее: модель стоимости ставит последовательный векторный план ниже параллельного строчного;
текстовые колонки из скана heap отдаются медленнее, чем строчным сканом;
force отказывается от индексных путей, кроме поиска ближайших соседей, поэтому на таблице с первичным ключом auto быстрее force.
Измерения
Все цифры получены на Docker-образах из закоммиченных веток, на моем 16-ядерном Ryzen 9 9955HX3D с 64 ГБ памяти, без assert-проверок, и каждый ответ сверен с эталоном DuckDB.
ClickBench прогонялся по своему протоколу на ванильном PostgreSQL 19 с ORCA, но на 10 млн строк, так что с опубликованными результатами на 100 млн эти цифры не сравнить. Среднее геометрическое таймингов «горячих» 43 запросов, мс:
Конфигурация | heap | heap с первичным ключом |
|---|---|---|
планировщик PostgreSQL | 625 | 402 |
планировщик PostgreSQL + vexec, auto | 506 | 311 |
планировщик PostgreSQL + vexec, force | 498 | 365 |
ORCA | 1647 | 924 |
ORCA + vexec, auto | 1349 | 745 |
vexec снимает 19-24% у планировщика PostgreSQL в режиме auto и 16-19% у ORCA, лучшие запросы ускоряются в 8.8-9.5 раза: Q15 и Q16 на стандартном планировщике, Q29 на ORCA. На стандартном планировщике Q10, Q11 и Q13 с ‘COUNT(DISTINCT)’ замедлились, на ORCA - Q25 и Q39, где скан отдает текстовые колонки. ORCA отстает от планировщика в 2.3-2.6 раза прежде всего из-за последовательных планов.
Flight SQL, TPC-H Q1 на SF1, медиана пяти прогонов от execute до таблицы Arrow в клиенте:
ванильный PostgreSQL 19 | координатор порта, 2 сегмента | |
|---|---|---|
‘adbc_driver_postgresql’, vexec выключен | 0,743 с | 1,115 с |
‘adbc_driver_postgresql’, vexec auto | 0,400 с | 0,567 с |
‘adbc_driver_flightsql’, vexec auto | 0,389 с | 0,555 с |
первый миллион строк lineitem, ‘adbc_driver_postgresql’, vexec выключен | 0,917 с | 1,012 с |
то же через ‘adbc_driver_flightsql’, vexec auto | 0,194 с | 0,748 с |
Время Q1 - время исполнителя, и vexec сокращает его почти вдвое, через какой драйвер ни читай четыре строки результата. Транспорт виден на миллионе строк: Flight отдает их в 4.7 раза быстрее бинарного COPY. На порте замер сделан до V7, и результат еще шел через Gather Motion строками.
Вставка 200 тысяч строк lineitem, медиана трех загрузок (строк в секунду):
Путь | porc_vec, один узел | ao_column, один узел | heap, один узел | porc_vec, 2 сегмента |
|---|---|---|---|---|
COPY CSV из файла сервера | 955 491 | 1 176 792 | 1 294 406 | 568 093 |
бинарный COPY через ADBC | 547 494 | 635 661 | 651 960 | 399 412 |
Flight SQL до VI: DoPut, портал на строку | 27 466 | - | - | 771 |
Flight SQL ‘adbc_ingest’ через VecIngest и VecInsert | 1 580 626 | 1 826 448 | 1 545 523 | 647 592 |
В porc_vec Flight загружает в 1.65 раза быстрее COPY CSV и в 2.9 раза быстрее бинарного COPY. На кластере выигрыш пока 1.14 раза: поток пересекает Motion строками и ждет фреймов V7.
Честности ради: первый замер TPC на четырех сегментах после V2, когда векторными были только сканы и агрегация, дал в среднем около единицы. Q1 ускорился в 1.8-2.8 раза, а там, где векторный скан кормит строчный hash join, терялось до половины скорости, поэтому дальше пошли VecHashJoin и страничное чтение heap. Их замер ждет тихого хоста.
Процесс разработки
Все началось с запроса к Claude Code: как сделать векторизованный исполнитель внутри PostgreSQL - переиспользовать ORCA с векторным планированием поверх колоночных форматов Cloudberry или добавить в ядро хуки колоночного доступа. Девять исследовательских отчетов разобрали код PostgreSQL 19, порта Cloudberry, PAX, AOCO, Arrow и предшественников. План вырос до 4,7 тысячи строк и 113 тысяч слов, каждое утверждение в нем - ссылка вида ‘файл:строки’ или число, измеренное на этом хосте, а в шапке журналируются мои вопросы и то, что после них поменялось в коде.
Полтора десятка моих вопросов еще до первой строки кода изменили архитектуру: два формата батча, ключи из буферов Arrow, общая память для сегментов одного хоста, Flight SQL отдельным расширением, колоночная вставка без изменений ядра, пакеты ядер, измерения производительности ClickBench до начала разработки. От IPC из nanoarrow я отказался: ее код записи копировал бы каждый буфер батча, не понимает string view и выделяет память через malloc.
Фаза | Дата | Результат |
|---|---|---|
VB | 5 окт. | бейзлайн ClickBench до первой строки vexec |
V0 | 5 окт. | каркас: оракул, модель стоимости, два формата батча, контракт источников |
V1 | 5 окт. | VecScan, VecResult, компилятор выражений, оба планировщика, сегменты, читатели PAX и gp_ao |
V2 | 6 окт. | VecAgg, агрегаты из статистики PAX, первый замер |
V3 | 6 окт. | VecHashJoin |
V4 | 6 окт. | страничный heap, параллельные пути, VecSort, VecRepartition, VecBitmapHeapScan |
V5 | 6 окт. | CCostModelVec, VecWindowHashAgg |
V6 | 6 окт. | ORCA на ванильном PostgreSQL 19 |
VK | 6 окт. | пакеты ядер для pgvector и PostGIS |
V7_0, V10 | 6 окт. | кодек Arrow IPC, egress API, vexec_flight |
VI | 7 окт. | приемники данных, VecInsert, вставка из Flight SQL |
V7 | 7 окт. | кадры Arrow через Motion, транспорт shm |
По времени это выглядело так:

и все еще в процессе оптимизаций и улучшения, но основной функционал проекта уже готов.
Результаты
За три дня разработки я создал векторизованный исполнитель и планировщик запросов в виде расширения PostgreSQL 19, который:
работает на ванильном ядре постгреса без единого патча, в том числе с ORCA, и расширение одной настройкой возвращает к ванильному поведению всю СУБД;
превращает ORCA в векторный планировщик в одноузловой конфигурации, а также на каждом сегменте MPP-кластера;
позволяет передавать в колоночном формате данные из PAX и ao_column в память исполнителя и обратно без лишних сериализаций/десериализаций и передает батчи между сегментами по Motion в формате Arrow;
взаимодействует с клиентами через FlightSQL прямо из структур данных в памяти исполнителя;
обрабатывает типы данных PostGIS и pgvector их собственными реализациями функций в колоночных структурах в памяти исполнителя запросов;
работает как PostgreSQL а не абсолютно новая технология: 239 регрессионных тестов без правок ожидаемых ответов, а результаты запросов TPC и ClickBench также совпадают с эталонными ответами.
В итоге около 43 тысяч строк C кода в vexec, 7.9 тысячи строк в vexec_flight, тысяча в пакетах ядер для pgvector/PostGIS и около 13.6 тысячи строк в модулях порта Cloudberry ‘pg19/’, включая транспорт shm и копии файлов PAX. В ближайших планах дальнейшие оптимизации и измерения производительности и запуск Cloudberry на колоночных таблицах. Конечно проекту есть куда развиваться и что оптимизировать, но даже на данном этапе реализовано гораздо больше чем простой прототип ускорения PostgreSQL. Получилось open source расширение базы данных, которое лучше интегрируется с экосистемой AI/ML общаясь на Apache Arrow по FlightSQL с внешним миром, но при этом поддерживает и существующие драйверы PostgreSQL.
Код проекта: pg_vexec (vexec), pg_vexec_flight, pg_vexec_pgvector и pg_vexec_postgis; необходимые для векторизации изменения порта Cloudberry находятся в pg19/.

