<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <title>Igor Suhorukov</title>
  <subtitle>Blog posts about open-source and technology.</subtitle>
  <id>https://igor-suhorukov.github.io/blog/feed/postgresql.xml</id>
  <link href="https://igor-suhorukov.github.io/blog/feed/postgresql.xml" rel="self" type="application/atom+xml"/>
  <link href="https://igor-suhorukov.github.io/blog/" rel="alternate" type="text/html"/>
  <updated>2026-10-21T08:02:26+00:00</updated>
  <author>
    <name>Igor Suhorukov</name>
    <uri>https://igor-suhorukov.github.io/blog/</uri>
  </author>
  <entry>
    <title type="html">My PostgreSQL is faster than yours and has almost caught up with ClickHouse</title>
    <link href="https://igor-suhorukov.github.io/blog/2026/10/21/my-postgresql-is-faster-than-yours/" rel="alternate" type="text/html"/>
    <id>https://igor-suhorukov.github.io/blog/2026/10/21/my-postgresql-is-faster-than-yours</id>
    <published>2026-10-21T00:00:00+00:00</published>
    <updated>2026-10-21T00:00:00+00:00</updated>
    <author>
      <name>Igor Suhorukov</name>
    </author>
    <category term="postgresql"/>
    <category term="clickhouse"/>
    <category term="mergetree"/>
    <category term="table-access-method"/>
    <category term="vectorization"/>
    <category term="clickbench"/>
    <summary type="html">ClickHouse&apos;s MergeTree table engine plugged into PostgreSQL 19 as a table access method, without a fork or a single core patch, and read by the pg_vexec vectorized executor, plus iheap, a table access method that updates rows in place. On my laptop, ClickBench on 10 million rows runs only 3 times slower than in ClickHouse on the same machine.</summary>
    <content type="html" xml:base="https://igor-suhorukov.github.io/blog/2026/10/21/my-postgresql-is-faster-than-yours/">&lt;p&gt;A friend of mine is an excellent architect but a complete snob. He has four different databases in production: PostgreSQL for OLTP, ClickHouse for OpenTelemetry data and web analytics, Elasticsearch for full-text search in the application, and MongoDB, “for historical reasons.”&lt;/p&gt;

&lt;p&gt;In between, there are ETL and CDC pipelines, plus on-call developers hunting for where exactly the results don’t match. To him, it’s all a well-thought-out architecture; to me, it’s needless complexity in maintenance and development.&lt;/p&gt;

&lt;p&gt;The other day I told him what I’ve been working on lately.&lt;/p&gt;

&lt;p&gt;“I’ve managed to plug ClickHouse’s MergeTree table engine into PostgreSQL as a table access method, on a vanilla database core, without a fork, and with a vectorized query executor reading from it.”&lt;/p&gt;

&lt;p&gt;“What for? Just use ClickHouse and spare yourself the pain.”&lt;/p&gt;

&lt;p&gt;“Because I need JOINs across lots of tables, consistent data throughout the system, and one platform that’s familiar to develop and maintain.”&lt;/p&gt;

&lt;p&gt;“That’s not how it works. It’s either reliable or fast!”&lt;/p&gt;

&lt;p&gt;“Well, I’ve managed to combine the best of both worlds.”&lt;/p&gt;

&lt;h2 id=&quot;vectorization-hit-a-wall-in-heap&quot;&gt;Vectorization hit a wall in heap&lt;/h2&gt;

&lt;p&gt;“Wait a minute. Didn’t you just port Greenplum into a Postgres extension and then write a vectorized executor on top of it? What do you need MergeTree for?”&lt;/p&gt;

&lt;p&gt;“Vectorization grinds through columns fast, but it chokes the moment it has to read the data.”&lt;/p&gt;

&lt;p&gt;The backstory began when I ported &lt;a href=&quot;https://igor-suhorukov.github.io/blog/2026/09/30/greenplum-without-the-fork/&quot;&gt;Apache Cloudberry to PostgreSQL 19&lt;/a&gt; as a set of extensions, which got me ORCA, Greenplum’s cost-based planner, on PostgreSQL 19. On top of that query optimizer I developed &lt;a href=&quot;https://igor-suhorukov.github.io/blog/2026/10/07/vectorized-postgresql-extension/&quot;&gt;pg_vexec&lt;/a&gt;, a vectorized executor without a single core patch.&lt;/p&gt;

&lt;p&gt;Its nodes, VecScan, VecHashJoin, VecAgg and VecSort, get into a query plan through the database’s standard extension mechanisms and compete with the ordinary nodes on cost. ORCA prices the vector nodes with its own cost model. Inside, the executor processes batches of 1,024 rows in columnar form with kernels bound to function OIDs. Where there is no dedicated kernel, the stock Postgres expression interpreter evaluates the expression right inside the node.&lt;/p&gt;

&lt;p&gt;The first ClickBench measurement didn’t make me happy: vectorization didn’t speed things up much. With the standard planner on heap, pg_vexec cut the geometric mean query time by only 19%, from 625 to 506 ms, and under ORCA by 18%, from 1,647 to 1,349 ms.&lt;/p&gt;

&lt;p&gt;“18%, seriously? Was that worth building all the vectorization for?”&lt;/p&gt;

&lt;p&gt;“Well, I ran a profiler and saw that the function kernels took only 0–2% of the total query execution time. Depending on the query, deforming wide rows ate 51–86% of the cycles of a GROUP BY, and hash table lookups 5%. Another 26–47% went to copying pages.”&lt;/p&gt;

&lt;p&gt;When a row weighs about 1.4 KB, 5–6 rows fit on a page, and a batch of 1,024 rows is smeared across roughly 180 pages. To get three of the benchmark table’s 105 columns, heap dutifully pulls whole pages into memory. The Postgres table API hands out rows one at a time through a slot, and scan_begin() doesn’t even take a list of the columns the query needs.&lt;/p&gt;

&lt;p&gt;Nor is there any way to skip the pages you don’t need: heap offers no extension point for that. A simulation of such skipping made the same ClickBench queries 8–100× faster (Q20 went from 510 to 5.3 ms). But it was only a simulation.&lt;/p&gt;

&lt;p&gt;“So use a columnar store. PostgreSQL has a dozen of them.”&lt;/p&gt;

&lt;p&gt;“Thanks for the advice, Captain Obvious :) On unmodified PostgreSQL 19, vexec couldn’t find a single source that would hand it data column by column. Citus columnar and Hydra are columnar inside, but through the table AM API they hand out the very same slots, one row at a time. Greenplum’s PAX and AOCO live only on the port’s patched Postgres core: besides the extensions, the port also has a series of core patches. Sure, on 16 segments of gp_ao with ORCA + vexec the port squeezed out 105 ms, but that’s sixteen segments against a single node, and still a fork of the database core, which is exactly what I wanted to get away from.”&lt;/p&gt;

&lt;p&gt;That’s how my plan got a new task: bring in the MergeTree family of table engines and try to catch up with ClickHouse on ClickBench.&lt;/p&gt;

&lt;h2 id=&quot;how-i-cut-the-mean-query-time-from-715-to-49-milliseconds&quot;&gt;How I cut the mean query time from 715 to 49 milliseconds&lt;/h2&gt;

&lt;p&gt;The very first, still unoptimized version of MergeTree together with pg_vexec immediately brought the geometric mean query time down to 148 ms from the 715 ms of heap without vectorization in the October 5 measurement. After that came a programmer’s usual routine: profiling, hypotheses, prototyping, and rolling changes back.&lt;/p&gt;

&lt;p&gt;GCC’s auto-vectorization at -O2, which PGXS builds with, gave almost nothing: exactly one loop got vectorized, and not a single comparison. Once the code was rewritten in SSE2 with movemask, with an LLM’s help, comparing 1,024 rows took 82 ns instead of 426.&lt;/p&gt;

&lt;p&gt;The code that decides whether an expression can throw an error had no branch for T_TargetEntry. As a result, VecHashJoin read its row-based child one row at a time, and because of that TPC-DS Q2 assembled 2,153,556 single-row batches. A one-line fix gave 1,246 ms instead of the original 3,447.&lt;/p&gt;

&lt;p&gt;In auto mode the planner wouldn’t put a VecSort above the scan node, and in force mode VecSort’s cost went negative: -46403! After the fix, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT * FROM hits ORDER BY CounterID LIMIT 10&lt;/code&gt; runs in 67–76 ms instead of the original 2.1 seconds.&lt;/p&gt;

&lt;p&gt;The scan first asks the data source for the columns of the first condition and reads the rest only for the batches where rows remain after that condition. On Q23, single-threaded, this cut the execution time from 471 to 247 ms.&lt;/p&gt;

&lt;p&gt;Columns with few distinct values now arrive in pg_vexec as indexes into a LowCardinality dictionary, and expressions are computed once per dictionary value. Q10, single-threaded: 85.6 ms instead of 148.2.&lt;/p&gt;

&lt;p&gt;PostgreSQL’s regular expression engine builds its DFA from scratch on every call, and in Q28 its miss() function ate 36% of the cycles. A dedicated kernel in the vectorizer plus partitioned aggregation in shared memory cut Q28’s execution time more than fivefold, from 1,090 to 213 ms. Across the whole of ClickBench, that saved 16%.&lt;/p&gt;

&lt;p&gt;To be honest, not every step led to an improvement. A table with LowCardinality, small granules and no compression suddenly got slower and took 11.8 GB on disk instead of 4.8 GB. The disabled compression ate the space, but the slowdown had nothing to do with the options: the test bench restarted the server before the merges had finished, and the queries read 35 parts instead of three. Even the original table gave 106 ms on 35 parts, against 71. The biggest speedup for pg_vexec came with the initial work on MergeTree; after that, progress was slow and painstaking, with many forks in the road about where to take the project next.&lt;/p&gt;

&lt;h2 id=&quot;yet-another-reinvented-columnar-wheel&quot;&gt;Yet another reinvented columnar wheel&lt;/h2&gt;

&lt;p&gt;That’s exactly how I started, with a wheel of my own, but I quickly sent it to the scrap heap. MergeTree has been tested for years in user analytics, under real load and real business queries. And of course, you can’t win at ClickBench without the technologies the benchmark was written for: ClickHouse’s data formats, and background sorting and merging in storage.&lt;/p&gt;

&lt;p&gt;Now you can run ch_table_engines in Postgres and get storage tailored to analyzing server logs:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;-- postgresql.conf: shared_preload_libraries = &apos;ch_table_engines&apos;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;EXTENSION&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ch_table_engines&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;hits&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(...)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;USING&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mergetree&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;WITH&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;order_by&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;CounterID, EventDate, UserID&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;storage&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;pages&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;“So you just linked ClickHouse into a Postgres backend?”&lt;/p&gt;

&lt;p&gt;“It’s not that simple. MergeTree doesn’t build on its own: it’s part of ClickHouse’s dbms library and drags the interpreter, the parsers and the functions along with it. Of about a hundred and fifty submodules, only 28 were needed. Static linking produces libchte_engine.so. Nor can you take the ready-made StorageMergeTree, with its multithreaded model of a single server process. When a second process tries to get into its directory, you get ‘Another server instance in same directory is already running’, and Postgres creates a separate process for every connection. So it took some duct tape of my own to strap it onto PostgreSQL.”&lt;/p&gt;

&lt;p&gt;But MergeTree is of little use with the standard Postgres executor. Without the vectorized executor, MergeTree lost even to heap: under ORCA, the TPC-H SF1 queries took 61.8 s in total against 17.2 s for heap with the stock executor, and TPC-DS 380.5 s against 58.8 s. With pg_vexec, the same queries on MergeTree ran in 6.9 s and 48.6 s. Without that piece, you’d get a columnar store that hands out data row by row through a slot, like a slow heap.&lt;/p&gt;

&lt;h2 id=&quot;update-doesnt-bloat-the-table&quot;&gt;UPDATE doesn’t bloat the table&lt;/h2&gt;

&lt;p&gt;“Fine, so much for analytics. But admit it: updating data in Postgres while long transactions are running is a pain. Every UPDATE is a new row version, dead tuples pile up, and autovacuum huffs and puffs. When we first put our system into production, table bloat was a problem for us. We had to shorten our transactions and move the analytical queries out of Postgres.”&lt;/p&gt;

&lt;p&gt;“I do admit it! That’s exactly why I made iheap, a PostgreSQL 19 table access method that overwrites a row in place, at the same TID. Its old version goes to an undo log, and only a reader whose snapshot needs it rebuilds it from there.”&lt;/p&gt;

&lt;p&gt;The idea is similar to zheap, a project that people spent years trying to get into the core before abandoning it. I, however, managed to do something similar without a core patch. In PostgreSQL 19, the database’s built-in mechanisms are enough to implement it, from a custom WAL resource manager to visibility horizons.&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;-- postgresql.conf: shared_preload_libraries = &apos;pg_inplace_update_am&apos;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;EXTENSION&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pg_inplace_update_am&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;accounts&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;PRIMARY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;KEY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;balance&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;USING&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;iheap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;ALTER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;old_table&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;SET&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ACCESS&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;METHOD&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;iheap&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;-- convert an existing table&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Undo lives in extents of the table’s own main fork, so WAL, crash recovery, replicas, backups and logical replication work as they do for any table. Row locks are undo records too, so there are no MultiXacts and no freezing. And so that an update of an indexed column doesn’t touch the other indexes, the extension carries dmbtree, a fork of PG19’s btree that can mark an old entry as deleted: a new entry appears only in the indexes whose keys changed, not in every index of the table.&lt;/p&gt;

&lt;h2 id=&quot;my-postgresql&quot;&gt;My PostgreSQL&lt;/h2&gt;

&lt;p&gt;“All right. But why all this if ClickHouse is faster anyway?”&lt;/p&gt;

&lt;p&gt;“Because it’s a great database for a huge metrics table with hundreds of columns. And what if the database also has to hold transactional data from different systems, with dozens of joins between tables? You can get all of it in PostgreSQL, in a single transaction. And a long-running transaction is no longer such a big problem under frequent updates: iheap tables don’t bloat the way heap tables do. Unless you’re Big Tech, why pile up the complexity of maintaining several systems?”&lt;/p&gt;

&lt;p&gt;On my laptop, PostgreSQL 19 ran the ClickBench queries on 10 million rows only 3 times slower than ClickHouse with the same data on the same machine: 49.9 ms against 16.8 ms by geometric mean. Vanilla Postgres was 39 times slower than ClickHouse in those runs. The result is my own set of PostgreSQL extensions, &lt;a href=&quot;https://github.com/igor-suhorukov/pg_vexec&quot;&gt;pg_vexec&lt;/a&gt;, &lt;a href=&quot;https://github.com/igor-suhorukov/pg_ch_table_engines&quot;&gt;ch_table_engines&lt;/a&gt; and &lt;a href=&quot;https://github.com/igor-suhorukov/pg_inplace_update_am&quot;&gt;iheap&lt;/a&gt;, and not a single core patch. You simply choose the storage with the standard &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CREATE TABLE ... USING&lt;/code&gt; syntax: mergetree for metrics and wide tables, iheap for update-heavy tables without table and index bloat. Replicas, pg_dump and MVCC work as they do with ordinary heap, because PostgreSQL still takes care of them.&lt;/p&gt;

&lt;p&gt;A specialized database means extra complexity in development and maintenance, unless your data is on the scale of the Large Hadron Collider’s telemetry or Google’s. Good old PostgreSQL can still hold its own against modern databases in everyday projects!&lt;/p&gt;</content>
  </entry>
  <entry>
    <title type="html">Vectorized Query Execution in PostgreSQL 19: Arrow, ORCA and Flight SQL in a PostgreSQL Extension</title>
    <link href="https://igor-suhorukov.github.io/blog/2026/10/07/vectorized-postgresql-extension/" rel="alternate" type="text/html"/>
    <id>https://igor-suhorukov.github.io/blog/2026/10/07/vectorized-postgresql-extension</id>
    <published>2026-10-07T00:00:00+00:00</published>
    <updated>2026-10-07T00:00:00+00:00</updated>
    <author>
      <name>Igor Suhorukov</name>
    </author>
    <category term="postgresql"/>
    <category term="vectorization"/>
    <category term="arrow"/>
    <category term="flight-sql"/>
    <category term="orca"/>
    <category term="cloudberry"/>
    <summary type="html">pg_vexec, a family of extensions for PostgreSQL 19: a vectorized planner and executor, an Arrow Flight SQL endpoint, and kernel packs for pgvector and PostGIS. PostgreSQL&apos;s planner and ORCA choose vector nodes by cost, the server needs no core patches, and in the Cloudberry port, batches travel between segments as Arrow IPC frames.</summary>
    <content type="html" xml:base="https://igor-suhorukov.github.io/blog/2026/10/07/vectorized-postgresql-extension/">&lt;p&gt;Last week &lt;a href=&quot;https://igor-suhorukov.github.io/blog/2026/09/30/greenplum-without-the-fork/&quot;&gt;I ported Apache Cloudberry to PostgreSQL 19&lt;/a&gt; as a set of extensions and described where the port falls behind the original fork. On ClickBench, columnar engines beat it by an order of magnitude. The culprit isn’t the MPP implementation but PostgreSQL’s executor, which works on rows. Each row travels up the node tree on its own, every value goes through fmgr, and the cluster’s segments merely add more processors running the very same loop over table rows.&lt;/p&gt;

&lt;p&gt;The logical next step is a vectorized executor. Apache Cloudberry’s open-source code doesn’t have one: only traces of the closed-source engine remain in the repository, namely the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;create_vectorization_plan&lt;/code&gt; flag, which the open-source planner always passes as false, a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WindowHashAgg&lt;/code&gt; node with no executor behind it, and a PAX adapter under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VEC_BUILD&lt;/code&gt; that hasn’t compiled for a long time. I would have to design it myself and, of course, as an extension once again.&lt;/p&gt;

&lt;p&gt;That is how pg_vexec came about, a family of extensions for PostgreSQL 19:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec&lt;/code&gt;: a vectorized planner and executor in one module;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec_flight&lt;/code&gt;: an Arrow Flight SQL endpoint that serves and accepts Arrow while the vectorized executor is on;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec_pgvector&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec_postgis&lt;/code&gt;: kernel packs that let pgvector’s and PostGIS’s functions compute over batches.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project doesn’t require modifying PostgreSQL, even with the ORCA planner. The gp_orca module from the Cloudberry port now builds on its own, without gp_core and without core patches, and vexec turns ORCA into a vectorized engine: ORCA’s search weighs its alternatives with the costs of vector nodes, and its translator builds those nodes directly. Core patches are needed only if Cloudberry itself is loaded into the server. Then vexec can also read the columnar PAX and ao_column tables column by column and write to them the same way, without turning the data into rows, and between segments the Motions carry data as Arrow IPC frames.&lt;/p&gt;

&lt;p&gt;Once again Claude Code wrote the code for me, in parallel sessions, each in its own git worktree, while I set the tasks, made the architectural decisions and checked the results. I started on the plan for a vectorized PostgreSQL extension on October 2, the code started on October 5, and by the morning of October 7 thirteen iterations had been built, from the first ClickBench run for the baseline to sending columnar data as Arrow from the database’s memory between servers through Motions.&lt;/p&gt;

&lt;h2 id=&quot;it-started-with-datafusion&quot;&gt;It started with DataFusion&lt;/h2&gt;

&lt;p&gt;It all began a month and a half ago with the idea of embedding a ready-made vectorized engine, Apache DataFusion, in PostgreSQL. This had already been done in open source before me, but I wanted the new solution to be distributed and faster. In the plan for pg_arrow, a Rust core was to sit behind a C ABI, with everything that touches PostgreSQL’s headers kept in a thin C layer. DataFusion would live in a single background worker per cluster, with its own Tokio thread pools. A backend would offer the planner &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ArrowScan&lt;/code&gt; CustomPaths through the path hooks, translate the chosen subtree from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PlannerInfo&lt;/code&gt; into a DataFusion logical plan and send it to the service. Producer processes would encode heap rows into Arrow pages in shared memory, results would come back as pages in PostgreSQL’s layout, Flight SQL would serve Arrow to the outside world straight from the service, and columnar tables would be stored in Iceberg on object storage.&lt;/p&gt;

&lt;p&gt;The more closely I worked through this plan with the agents, the longer the list of compromises grew. DataFusion’s semantics aren’t PostgreSQL’s: collations, numeric scale, the ordering of NaN, time zones, and even &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;now()&lt;/code&gt;, which DataFusion pins when it hands out a session state, so two statements of one transaction would see different times. jsonb and geometries would travel between processes as text and EWKB and be rebuilt on every row, and every data path added a copy: heap into Arrow pages, results back into PostgreSQL pages. The service would be a child of the postmaster, so an abort, a potential segfault in zstd or a visit from the OOM killer would restart the whole cluster. DataFusion 55.1’s hash join couldn’t spill to disk, datafusion-proto silently dropped plan fields, and until SubPlans, InitPlans, CTEs, partitions and eager aggregation were translated, such queries would stay on PostgreSQL’s plan.&lt;/p&gt;

&lt;p&gt;And the gain was questionable: the fastest parts of such an engine, vectorized scans, filters and aggregates, can be had inside the backend too, with PostgreSQL’s semantics and without copies between processes. So pg_arrow remained a plan without a single line of code. When my port of Cloudberry to extensions gave me ORCA, PAX and gp_ao as modules, I came up with a plan for vectorizing query execution: not one more engine next to PostgreSQL, but a vectorized executor inside the PostgreSQL DBMS itself. It kept several things from pg_arrow: the mapping table for Arrow types, pages in PostgreSQL’s layout as the prototype of the postgres format, the corpus of function semantics, Flight SQL logins through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_hba.conf&lt;/code&gt;, and even the definition of done for Flight SQL, which is that TPC-H Q1 through it must be faster than binary COPY.&lt;/p&gt;

&lt;p&gt;So, as in the refactorings of legacy systems at my previous jobs, I decided to clean out the Augean stables starting from the main dependency, the planner. I chose ORCA for that role and began preparing PostgreSQL for the idea: vectorized execution, and data transfer over Flight SQL in addition to pgwire.&lt;/p&gt;

&lt;h2 id=&quot;why-an-extension&quot;&gt;Why an extension?&lt;/h2&gt;

&lt;p&gt;Vectorizing PostgreSQL isn’t a new idea, and every way of doing it has had its price. openGauss built a vectorized executor of about 82,000 lines into a fork of PostgreSQL 9.2 that can no longer be merged with upstream. pg_duckdb hands the query to a second engine, DuckDB, right inside the backend, and it raises the same questions about semantics that pg_arrow did. And openGauss, vectorize_engine, Hydra and TimescaleDB’s VectorAgg insert vector nodes into a finished plan, so vectorization never competes on cost. In TimescaleDB this approach has led to wrong results (#9902, for example) and crashes.&lt;/p&gt;

&lt;p&gt;I gave the agents the same conditions as for the Cloudberry port: “plug the executor in only through PostgreSQL’s existing hooks, and without the extension loaded the server must behave like vanilla.” Six research reports checked every boundary of the design against the code, from planning to the cluster, and not one of them needed a new hook. Hence the rules for every phase:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;no core changes. On the whole, I got lucky! The vendors who built vectorized executors for PostgreSQL apparently got all the extension points and hooks this takes into the upstream core, except for vectorized reading and writing of data in the table AM. The core had all the extension points needed;&lt;/li&gt;
  &lt;li&gt;vector execution nodes are available to the planner and compete with row-based alternatives on cost. Nobody rewrites a finished plan;&lt;/li&gt;
  &lt;li&gt;one model for both planners: one capability oracle, one cost model in PostgreSQL’s units, one set of node builders;&lt;/li&gt;
  &lt;li&gt;PostgreSQL’s semantics down to the last SQLSTATE: NULLs, unevaluated CASE branches, collations, NaN, the order of float summation. A kernel only speeds things up, and completeness comes from the fallback to PostgreSQL’s expression evaluator;&lt;/li&gt;
  &lt;li&gt;Cloudberry’s sources (other than &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg19/&lt;/code&gt;) don’t change, and no code is copied from openGauss, Hydra or TimescaleDB;&lt;/li&gt;
  &lt;li&gt;the backend stays single-threaded and in C: palloc within &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;work_mem&lt;/code&gt;, spills to BufFile, errors through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ereport&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The extension’s vanilla behaviour is checked at every step of implementation, and checked hard, as in my Cloudberry port. PostgreSQL’s 239 regression tests pass against unchanged expected files with vexec installed, preloaded with mode off, and in explain mode. A differential runner compares the answers of force sessions in the postgres format, in the arrow format and with random column layouts against off, and every difference, such as plans that the tests print themselves, has been examined and recorded. My Cloudberry port’s suites likewise compare force with off, and the 121 TPC-H and TPC-DS queries are checked against DuckDB’s answers. And if a plan with a vector node reaches a segment where the library has been removed, the segment answers with a clean error, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExtensibleNodeMethods &quot;VecScan&quot; was not registered&lt;/code&gt;, and the cluster stays up.&lt;/p&gt;

&lt;h2 id=&quot;architecture-what-this-does-better-than-plain-postgresql-and-cloudberry&quot;&gt;Architecture: what this does better than plain PostgreSQL and Cloudberry&lt;/h2&gt;

&lt;p&gt;PostgreSQL and Cloudberry execute a query the same way, row by row. vexec replaces the rows in memory with a buffer of up to 1,024 rows laid out by column: that many rows of a fragment fit in the L1/L2 caches while they are being filtered, projected and aggregated. But the in-memory columnar structure itself is less interesting than where vector queries over this data are planned, and how data gets into this structure and out of it.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;PostgreSQL 19&lt;/th&gt;
      &lt;th&gt;Cloudberry without vexec&lt;/th&gt;
      &lt;th&gt;pg_vexec on vanilla PostgreSQL 19&lt;/th&gt;
      &lt;th&gt;pg_vexec in the Cloudberry port&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Unit of execution&lt;/td&gt;
      &lt;td&gt;a row&lt;/td&gt;
      &lt;td&gt;a row: the open-source code has no vectorized executor&lt;/td&gt;
      &lt;td&gt;a batch of up to 1,024 rows by column; kernels by function OID, fallback inside the same node&lt;/td&gt;
      &lt;td&gt;the same, on the coordinator and every segment&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Who decides what to vectorize&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
      &lt;td&gt;judging by PAX’s tests, the closed-source engine converts a finished plan node for node, with the row plan’s costs&lt;/td&gt;
      &lt;td&gt;both planners, by cost: PostgreSQL’s path hooks, and ORCA&lt;/td&gt;
      &lt;td&gt;ORCA, costing vector nodes, for MPP plans; PostgreSQL’s planner on the segments&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Table reads&lt;/td&gt;
      &lt;td&gt;heap through slots&lt;/td&gt;
      &lt;td&gt;AOCO and PAX return slots&lt;/td&gt;
      &lt;td&gt;heap page by page, with batched MVCC checks; other AMs through slots&lt;/td&gt;
      &lt;td&gt;PAX and ao_column return batches; aggregates from PAX’s statistics&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Inserts&lt;/td&gt;
      &lt;td&gt;ModifyTable row by row; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;multi_insert&lt;/code&gt; only in COPY FROM&lt;/td&gt;
      &lt;td&gt;the same; PAX and gp_ao split rows into columns themselves&lt;/td&gt;
      &lt;td&gt;VecInsert; into heap 1,000 rows at a time through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;table_multi_insert&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;VecInsert writes columns into PAX and ao_column without rows&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Between processes&lt;/td&gt;
      &lt;td&gt;Gather: a MinimalTuple per row&lt;/td&gt;
      &lt;td&gt;Motion: a MinimalTuple per row and an fmgr hash of the key per row&lt;/td&gt;
      &lt;td&gt;as in PostgreSQL&lt;/td&gt;
      &lt;td&gt;Arrow IPC frames, vectorized cdbhash, shared memory for segments on one host&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Arrow for clients&lt;/td&gt;
      &lt;td&gt;no: ADBC assembles Arrow from binary COPY&lt;/td&gt;
      &lt;td&gt;no&lt;/td&gt;
      &lt;td&gt;Flight SQL: results served from batches, inserts into VecInsert&lt;/td&gt;
      &lt;td&gt;the same, on the coordinator&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;PostGIS and pgvector functions&lt;/td&gt;
      &lt;td&gt;row by row&lt;/td&gt;
      &lt;td&gt;row by row&lt;/td&gt;
      &lt;td&gt;a batch’s rows at a time through fmgr, by the packs’ declarations&lt;/td&gt;
      &lt;td&gt;the same, on the segments&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Core changes&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
      &lt;td&gt;a fork; the port has 24 hook patches&lt;/td&gt;
      &lt;td&gt;none&lt;/td&gt;
      &lt;td&gt;only those the port itself needs&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Now in order, starting with what it is all built from.&lt;/p&gt;

&lt;h3 id=&quot;components&quot;&gt;Components&lt;/h3&gt;

&lt;figure class=&quot;diagram wide&quot;&gt;
  &lt;a href=&quot;https://igor-suhorukov.github.io/blog/assets/posts/vectorized-postgresql-extension/components.png&quot;&gt;&lt;img src=&quot;https://igor-suhorukov.github.io/blog/assets/posts/vectorized-postgresql-extension/components.png&quot; width=&quot;1941&quot; height=&quot;1629&quot; alt=&quot;Component diagram of pg_vexec. vexec, loaded through shared_preload_libraries, holds the vectorized planner (plan/: oracle, cost model, node builders, reasons for EXPLAIN), which turns a path or an ORCA node into a CustomScan vector node (exec/: VecScan, VecBitmapHeapScan, VecResult, VecAgg, VecHashJoin, VecSort, VecRepartition, VecWindowHashAgg, VecInsert, VecIngest, VecMotionSend, VecMotionReceive). The vector nodes use expressions (expr/: kernels by function OID, the packs&amp;#x27; declarations, fallback to ExecEvalExpr), sources (source/: heap&amp;#x27;s page reader, source and sink registries), batches (batch/: postgres and arrow formats, Arrow C Data Interface) and Motion frames (motion/: vectorized cdbhash), which, like the egress API (egress/: DestReceiver, ingest stream), use the Arrow IPC codec (ipc/). vexec_flight (Flight SQL on nghttp2, protobuf-c and OpenSSL) connects through vexec/egress_v1, the kernel packs vexec_pgvector and vexec_postgis declare functions through vexec/kernels_v1, and the Cloudberry port&amp;#x27;s PAX and gp_ao storage registers batch readers and sinks through vexec/source_v1 and vexec/sink_v1. vexec plugs into an unmodified PostgreSQL 19 core (REL_19_STABLE): the planner through planner_setup_hook, set_rel_pathlist_hook, set_join_pathlist_hook, create_upper_paths_hook, planner_shutdown_hook and explain_per_plan_hook; the executor through CustomScanMethods, ExecutorRun_hook and its own DestReceiver; heap and other table access methods through table_beginscan and heap_prepare_pagescan; and vexec_flight gets a background worker per connection from the postmaster. ORCA comes from the Cloudberry port&amp;#x27;s gp_orca, which on vanilla PostgreSQL is built with -Dorca_single_node, without gp_core, and is reached through Cloudberry/gp_orca_vec_v1: oracle, pricing, build_node.&quot; /&gt;&lt;/a&gt;
&lt;/figure&gt;

&lt;p&gt;vexec is a single module in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shared_preload_libraries&lt;/code&gt;, built with PGXS both for vanilla PostgreSQL 19 and for the Cloudberry extension port. The vectorized planner (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;plan/&lt;/code&gt;) is built into both planners and decides which operators become vector nodes. The executor is a set of CustomScan nodes (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;exec/&lt;/code&gt;) on top of a shared batch layer (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;batch/&lt;/code&gt;) and an expression compiler (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;expr/&lt;/code&gt;). Alongside them live the Arrow IPC codec (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ipc/&lt;/code&gt;), the egress API for Flight SQL (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;egress/&lt;/code&gt;) and the Motion frames (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;motion/&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;External links go through PostgreSQL’s rendezvous variables, with the major version in their names, the same way the port’s modules find gp_core’s API. Storage modules register batch readers in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec/source_v1&lt;/code&gt; and sinks in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec/sink_v1&lt;/code&gt;, vexec_flight finds &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec/egress_v1&lt;/code&gt;, the kernel packs find &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec/kernels_v1&lt;/code&gt;, and vexec itself finds ORCA through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Cloudberry/gp_orca_vec_v1&lt;/code&gt;. The order of the modules in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shared_preload_libraries&lt;/code&gt; doesn’t matter, and without the library loaded, a variable stays an empty entry in the server’s memory and nothing is registered.&lt;/p&gt;

&lt;h3 id=&quot;a-vectorized-planner-inside-the-database-not-next-to-it&quot;&gt;A vectorized planner inside the database, not next to it&lt;/h3&gt;

&lt;p&gt;In PostgreSQL’s planner, vexec adds paths through the planner’s own hooks: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecScan&lt;/code&gt; in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;set_rel_pathlist_hook&lt;/code&gt;, just as Citus’s columnar storage adds its scan, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecHashJoin&lt;/code&gt; in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;set_join_pathlist_hook&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecAgg&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecSort&lt;/code&gt; in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;create_upper_paths_hook&lt;/code&gt;. From there they compete with row paths in the ordinary &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;add_path&lt;/code&gt;. A vector path’s cost is derived from the row path’s, with multipliers for the kernels’ work, a columnar source estimates only the bytes of the columns it needs, and the transitions between rows and batches are chosen by cost. The strategy masks are honoured as well, so &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;enable_seqscan = off&lt;/code&gt; and pg_hint_plan’s hints apply to vector paths too. PostgreSQL calls no hook for partial aggregation, so vexec builds the pair of partial and final VecAggs around a Gather itself:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SET&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;vexec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;mode&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;force&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;EXPLAIN&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;COSTS&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;OFF&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;hw&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;k&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;sum&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;hp&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;JOIN&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;hw&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ON&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;hp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;hw&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;GROUP&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;hw&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;k&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt; Vec Finalize HashAggregate
   Group Key: k
   -&amp;gt;  Gather
         Workers Planned: 3
         -&amp;gt;  Vec Partial HashAggregate
               Group Key: k
               -&amp;gt;  Vec Hash Join
                     Hash Cond: (hp.id = hw.id)
                     -&amp;gt;  Parallel Vec Seq Scan on hp
                     -&amp;gt;  Vec Seq Scan on hw
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The path hooks can’t reach ORCA’s plans: ORCA builds its own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PlannerInfo&lt;/code&gt; and returns a finished plan. So the port uses a small API in gp_orca, through which vexec registers its oracle, its cost estimates and its node builders:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CCostModelVec&lt;/code&gt;, a subclass of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CCostModelGPDB&lt;/code&gt;, evaluates ORCA’s formulas twice, as they are and with vector multipliers, and blends the two by the share of steps that go to kernels. ORCA thus chooses join orders, aggregation stages and Motions with vectorized execution already in the cost;&lt;/li&gt;
  &lt;li&gt;the translator offers vexec every finished node, and vexec replaces it with a vector node if the oracle agrees, before the Motions are checked, the slice table is made and M8’s passes run, so every later step of the Cloudberry port sees the final tree;&lt;/li&gt;
  &lt;li&gt;ORCA’s hashed window aggregation, which PostgreSQL 19 has nothing to execute with, becomes a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecWindowHashAgg&lt;/code&gt; under an ordinary &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WindowAgg&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec.mode = explain&lt;/code&gt;, vexec costs the vector alternatives but doesn’t choose them, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;EXPLAIN (VEXEC)&lt;/code&gt; explains what was considered and why it wasn’t taken:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;SET&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;vexec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;mode&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;explain&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;EXPLAIN&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;VEXEC&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;COSTS&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;OFF&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;SELECT&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;count&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;sum&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;d&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;FROM&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;vt&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;WHERE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;a&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;GROUP&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;b&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;ORDER&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt; Sort
   Sort Key: b
   -&amp;gt;  HashAggregate
         Group Key: b
         -&amp;gt;  Seq Scan on vt
               Filter: (a &amp;gt; 10)
 Vexec: mode explain, format postgres
   VecScan on vt: not chosen (explain mode)
     source: heap&apos;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
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Planning doesn’t get any more expensive: EXPLAIN of all 121 TPC queries on four segments under ORCA took 9,715 ms without vexec and 9,696 ms with it, and under PostgreSQL’s planner 161 and 166 ms.&lt;/p&gt;

&lt;p&gt;In phase V6 I brought ORCA to vanilla PostgreSQL 19. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-Dorca_single_node&lt;/code&gt; build replaces gp_core with a stub of about 400 lines: it answers calls to gp_core’s API, and to the 14 of its functions that ORCA calls on a single node, the way gp_core would answer on a server without segments. With ORCA on vanilla &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;REL_19_STABLE&lt;/code&gt;, all 239 regression tests pass, 27 of them with examined differences, and the 121 TPC queries in force mode answer the way DuckDB does. Of the 652 hash joins in their plans, 651 became &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecHashJoin&lt;/code&gt;: all but a full join, which VecHashJoin can’t do yet.&lt;/p&gt;

&lt;h3 id=&quot;two-in-memory-data-formats&quot;&gt;Two in-memory data formats&lt;/h3&gt;

&lt;p&gt;The batch format is chosen by a setting; that was my requirement. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec.batch_format = postgres&lt;/code&gt;, the default, stores values the way PostgreSQL defines them, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;arrow&lt;/code&gt; stores them in standard Arrow types:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Type&lt;/th&gt;
      &lt;th&gt;postgres format&lt;/th&gt;
      &lt;th&gt;arrow format&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;bool&lt;/td&gt;
      &lt;td&gt;a byte per value&lt;/td&gt;
      &lt;td&gt;a bit per value&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;date, timestamp&lt;/td&gt;
      &lt;td&gt;PostgreSQL’s epoch, 2000-01-01&lt;/td&gt;
      &lt;td&gt;the Unix epoch&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;interval&lt;/td&gt;
      &lt;td&gt;PostgreSQL’s 16 bytes&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;month_day_nano&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;text, bytea&lt;/td&gt;
      &lt;td&gt;a Datum per value, with the varlena header&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;utf8_view&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;binary_view&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;int, float, uuid, numeric with a typmod of up to 38 digits&lt;/td&gt;
      &lt;td&gt;the same in both formats: values at the type’s width, numeric as scaled int64 or int128&lt;/td&gt;
      &lt;td&gt; &lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;PostgreSQL’s own boundaries cost the postgres format nothing: fmgr, slots, tuplesort, hash functions, and the heap, ao_column and porc storage. Arrow’s boundaries cost the arrow format nothing: export, Flight SQL, porc_vec and frames between processes. Conversions happen only at these named boundaries, and the cost model accounts for their price. The validity bitmap is shared by both: it has the same polarity and bit order as the NULL bitmap of a heap tuple. Kernels are generated for each layout, and four settings change the layout separately for strings, bool, dates and numeric. On ClickBench the two formats differed by no more than 2.6%.&lt;/p&gt;

&lt;p&gt;The scaled numeric was borrowed from openGauss as an idea but written from scratch to PostgreSQL’s numeric rules, and the gain on TPC-H Q1 is almost entirely its doing: with the varlena layout Q1 got 2–9% faster, with the scaled numeric 1.8–2.8× faster. The epochs have an edge case, though: the timestamp &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;294247-01-10 04:00:54.775807&lt;/code&gt;, shifted to the Unix epoch, gives exactly the int64 maximum, which PostgreSQL uses for +infinity, so a batch holding that timestamp keeps its column in PostgreSQL’s epoch.&lt;/p&gt;

&lt;h3 id=&quot;columnar-data-structures-from-storage&quot;&gt;Columnar data structures from storage&lt;/h3&gt;

&lt;p&gt;The source contract answers the question “why would the table AM need new hooks at all?” A storage module publishes &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;begin&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;next&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rescan&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;end&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;estimate&lt;/code&gt; functions through a rendezvous variable, and the scan is opened by the ordinary &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;table_beginscan&lt;/code&gt;, so the snapshot, predicate locks, parallel scan ranges, MVCC, pruning and deletes all stay with the access method. A deleted row goes into the selection bitmap, not into validity; otherwise &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;count(*)&lt;/code&gt; would count it.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;heap&lt;/strong&gt; is read by vexec itself, page by page, through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;heap_prepare_pagescan&lt;/code&gt;, the way TABLESAMPLE does it: page pruning, locking, PostgreSQL 19’s batched visibility check &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HeapTupleSatisfiesMVCCBatch&lt;/code&gt;, and the needed columns deformed straight into the batch. vexec doesn’t decide visibility with code of its own: in the port the snapshot is distributed.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;PAX&lt;/strong&gt; returns groups of up to 131,072 rows, sliced into batches without copying, and porc_vec stores almost Arrow’s layout. For &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aggregate()&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;count&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;min&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sum&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;avg&lt;/code&gt; without conditions are taken straight from the files’ and groups’ statistics, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;count(*)&lt;/code&gt; over 10 million rows takes 0.33 ms instead of 265. And through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;set_keys()&lt;/code&gt;, a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecSort&lt;/code&gt; with a LIMIT passes the scan a “running bound”, so PAX skips groups that can no longer make it into the answer.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ao_column&lt;/strong&gt; returns blocks of up to 16,384 rows: a fixed-width column without NULLs becomes a slice of the decompressed buffer, and a varlena column becomes Datums pointing into it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inserts mirror this. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecInsert&lt;/code&gt; takes the place of ModifyTable’s loop and writes a batch into the storage’s sink or, where there is none, through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;table_multi_insert&lt;/code&gt; 1,000 rows at a time, as COPY does. NOT NULL is checked against validity, and the first row that fails the check goes to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExecConstraints()&lt;/code&gt; so that the error is the same. Partitions are chosen by PostgreSQL’s functions, indexes get their entries through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExecInsertIndexTuples()&lt;/code&gt;, and once again the core didn’t need touching: COPY FROM inserts without ModifyTable too. On a cluster, ModifyTable stays on the coordinator, because gp_core dispatches the write through it, and VecInsert works under it on the segments. Triggers, foreign keys, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ON CONFLICT&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;RETURNING&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WITH CHECK&lt;/code&gt; keep the ordinary ModifyTable, and EXPLAIN names the reason.&lt;/p&gt;

&lt;figure class=&quot;diagram wide&quot;&gt;
  &lt;a href=&quot;https://igor-suhorukov.github.io/blog/assets/posts/vectorized-postgresql-extension/cloudberry-port.png&quot;&gt;&lt;img src=&quot;https://igor-suhorukov.github.io/blog/assets/posts/vectorized-postgresql-extension/cloudberry-port.png&quot; width=&quot;2285&quot; height=&quot;1456&quot; alt=&quot;Diagram of how vexec uses the Cloudberry port&amp;#x27;s components. vexec, the same module as on vanilla PostgreSQL, calls gp_orca&amp;#x27;s gp_orca_vec API 1.2 (oracle, pricing, build_node, describe_node, set_options, build_window): the DXL → plan translator offers every finished node to vexec, and CCostModelVec, a subclass of CCostModelGPDB, provides Cost() to ORCA&amp;#x27;s four unmodified core libraries. It calls gp_core&amp;#x27;s GpCoreApi 1.15 (Motions rebuilt, the policy&amp;#x27;s hash functions, hash_segment, squelch_subtree, explain_register), which leads to GpMotion and the interconnect (tcp, udpifc, udp2, proxy) and to cdbhash, squelch and the EXPLAIN ANALYZE report; shm, a Motion transport over shared memory, joins the transport table when gp.interconnect_type = shm. PAX (porc, porc_vec group reads, aggregate(), set_keys(), sink) and gp_ao (ao_column block reads, sink) connect through vexec/source_v1 and vexec/sink_v1. Dotted links show gp_core&amp;#x27;s dispatcher shipping fragments with Vec nodes to the segments (CustomScan by name) and sending the vexec.* settings to the segments; M8&amp;#x27;s parallel.c bound_gathers() sees vexec&amp;#x27;s nodes through describe_node. All of these are PostgreSQL 19 modules in pg19/, on top of PostgreSQL 19 with the port&amp;#x27;s 24 extension points, which Cloudberry itself needs and vexec doesn&amp;#x27;t call.&quot; /&gt;&lt;/a&gt;
&lt;/figure&gt;

&lt;p&gt;Everything vexec required of my Cloudberry port is module code in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg19/&lt;/code&gt;: gp_orca’s API and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CCostModelVec&lt;/code&gt;, the entry points of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GpCoreApi&lt;/code&gt; 1.15, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec.*&lt;/code&gt; names in the list of settings that the coordinator sends to the segments, PAX’s and gp_ao’s batch readers and sinks, and shm, the transport within one host. ORCA’s four core libraries compile from the Cloudberry tree unmodified. vexec doesn’t call the port’s core patches: it may use them indirectly, but only the ones PAX and gp_ao use, plus the memory hook, which counts its batches in resource groups like any module’s memory.&lt;/p&gt;

&lt;h3 id=&quot;arrow-ipc-through-motions&quot;&gt;Arrow IPC through Motions&lt;/h3&gt;

&lt;p&gt;Between segments, Cloudberry sends rows: for every row a Motion forms a MinimalTuple and calls the key’s hash function through fmgr, and rows go to the coordinator through libpq with a send and a receive for every value. If there are vector nodes on both sides of a Motion, a batch is taken apart into rows only to be put back together again.&lt;/p&gt;

&lt;p&gt;I asked whether the distribution keys could be read straight from Arrow buffers, and batches passed between nodes over Arrow Flight SQL. The first is done, the second isn’t: Flight would bring C++, gRPC and threads into the backends; flow control, the receiver’s STOP and the statement token, all of which the Cloudberry port’s interconnect already has, would have to be built again; and it would copy no less. So a batch travels over the port’s transports as an Arrow IPC frame, and the Motion code itself doesn’t change:&lt;/p&gt;

&lt;figure class=&quot;diagram wide&quot;&gt;
  &lt;a href=&quot;https://igor-suhorukov.github.io/blog/assets/posts/vectorized-postgresql-extension/motion-frames.png&quot;&gt;&lt;img src=&quot;https://igor-suhorukov.github.io/blog/assets/posts/vectorized-postgresql-extension/motion-frames.png&quot; width=&quot;1189&quot; height=&quot;1922&quot; alt=&quot;Flowchart of V7: Arrow batches through Motions as Arrow IPC frames. On the segments in slice 2, Vec Seq Scan on lg_h passes batches to Vec Motion Send, which finds each row&amp;#x27;s segment with a vectorized cdbhash, splits the batch by receiving segment and sends a schema frame to each receiver once; its frames go into an Explicit Redistribute Motion whose row is (NULL..., target int4, frame bytea). The interconnect (tcp, udpifc, udp2, proxy, or shm: a ring in a memfd, eventfd) delivers them to Vec Motion Receive in slice 1, where the batch&amp;#x27;s columns point into the frame; its batches and those of Vec Seq Scan on lg_n feed Vec Hash Join, then Vec Partial Aggregate and Vec Motion Send (Frames To: the one gathering), whose frames go through Gather Motion 3:1 as bytea without send/receive for every value. On the coordinator, gp_core&amp;#x27;s gather fetches up to 8 MB of frames through a binary libpq cursor, and Vec Motion Receive passes batches to Vec Finalize Aggregate. A note on frames: 16 bytes of vexec&amp;#x27;s own (VXF1, 0, schema hash) and one Arrow IPC message, Schema or RecordBatch; in the arrow format the batch&amp;#x27;s buffers go as they are, in the postgres format whole varlenas with their headers, and the receiver&amp;#x27;s Datums point into the frame. A note on shm: the sender creates a memfd and passes the descriptor to the receiver over a UNIX socket (SCM_RIGHTS) after the statement token; writing a frame into the ring is the only copy, the receiver reads it in place, and a Broadcast is written once, into a shared ring.&quot; /&gt;&lt;/a&gt;
&lt;/figure&gt;

&lt;ul&gt;
  &lt;li&gt;When the translator offers vexec a Motion whose sending fragment has a vector node at its top, vexec puts a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecMotionSend&lt;/code&gt; under it and a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecMotionReceive&lt;/code&gt; over it, and rebuilds the Motion itself in the same place through gp_core’s setters: a Redistribute becomes an Explicit Redistribute by segment number. A row of such a Motion is the fragment’s columns, all NULL, the segment number and the frame as a bytea.&lt;/li&gt;
  &lt;li&gt;A frame is 16 bytes of vexec’s own (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VXF1&lt;/code&gt;, a reserved field, the schema hash) followed by an Arrow IPC message from V7_0’s codec. In the arrow format the buffers go out as they are; in the postgres format a varlena is written whole, and the receiver’s Datums point straight into the frame. The schema goes to each receiver once and is kept there by its hash, since the frames of all the senders arrive interleaved.&lt;/li&gt;
  &lt;li&gt;The vectorized cdbhash reproduces &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GpHashSegment&lt;/code&gt; bit for bit, otherwise rows would go to the wrong segments: kernels for integers, float, text, uuid and dates, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hash_numeric&lt;/code&gt; reproduced from the scaled integer, and a row-by-row path through gp_core for legacy keys.&lt;/li&gt;
  &lt;li&gt;A &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecHashJoin&lt;/code&gt; that no longer needs its side with a Motion stops that side’s senders through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;squelch_subtree()&lt;/code&gt;, and the vector nodes’ counters come back from the segments into EXPLAIN ANALYZE.&lt;/li&gt;
  &lt;li&gt;Frames go to the coordinator through the same binary libpq cursor, without a send and a receive for every value.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For segments on one host, the port gained the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shm&lt;/code&gt; transport: the sender creates an anonymous file with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;memfd_create&lt;/code&gt;, passes the descriptor to the receiver through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SCM_RIGHTS&lt;/code&gt; after the statement token and writes frames into a ring, and the receiver reads them in place, so writing into the ring is the only copy. Both sides sleep on an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;eventfd&lt;/code&gt; in their &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WaitEventSet&lt;/code&gt;, so cancellation and timeouts work as they do over tcp, and the anonymous mapping disappears along with the last process, even one that crashed. Between hosts, a slice goes over tcp.&lt;/p&gt;

&lt;p&gt;Both switches take effect from the next statement: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec.enable_motion_frames&lt;/code&gt; is read at planning time and calls &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ResetPlanCache()&lt;/code&gt; when its value changes, so prepared statements and PL/pgSQL are planned again, and the transport is chosen by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp.interconnect_type&lt;/code&gt;. A sorted Gather, which merges rows, a write’s Motion and a Motion over a row node are not framed. Here is a plan from a test cluster of three segments:&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt; Vec Finalize Aggregate
   -&amp;gt;  Vec Motion Receive
         -&amp;gt;  Gather Motion 3:1  (slice1; segments: 3)
               -&amp;gt;  Vec Motion Send
                     Frames To: the one gathering
                     -&amp;gt;  Vec Partial Aggregate
                           -&amp;gt;  Vec Hash Join
                                 Hash Cond: (h.n2 = x.n)
                                 -&amp;gt;  Vec Motion Receive
                                       -&amp;gt;  Explicit Redistribute Motion 3:3  (slice2; segments: 3)
                                             -&amp;gt;  Vec Motion Send
                                                   Frames To: the segments their keys hash to
                                                   Hash Key: n2
                                                   -&amp;gt;  Vec Seq Scan on lg_h h
                                 -&amp;gt;  Vec Seq Scan on lg_n x
 Optimizer: GPORCA
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The cluster tests pass both over tcp and under shm, where all 2,777 senders and 2,777 receivers went through rings, and after a receiver is terminated, neither a mapping nor a file in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/dev/shm&lt;/code&gt; is left behind. On wide rows of 1.8 KB, shm came out faster: about 80 ms against 93 over tcp.&lt;/p&gt;

&lt;h3 id=&quot;arrow-both-in-the-databases-memory-and-on-the-wire-with-flight-sql&quot;&gt;Arrow both in the database’s memory and on the wire with Flight SQL&lt;/h3&gt;

&lt;p&gt;Today, Arrow clients read PostgreSQL through ADBC and binary COPY: the server sends rows, and the driver assembles columns from them. vexec_flight serves Arrow straight from batches, and only while the vectorized executor is on: the acceptor starts only if &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec_flight.listen_addresses&lt;/code&gt; is set and vexec is loaded, with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec.mode = off&lt;/code&gt; a statement gets &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FAILED_PRECONDITION&lt;/code&gt;, and the extension has no row path to the client at all.&lt;/p&gt;

&lt;p&gt;For BI and ML, Flight SQL is more than just another transport. Over pgwire a result travels as rows: every value goes through its type’s send function or is turned into text, a driver such as psycopg or JDBC assembles rows, and then pandas, Polars or DuckDB lay them out by column all over again. Over Flight SQL the client receives Arrow record batches, the very layout these libraries keep their data in anyway: pyarrow hands over a fixed-width column without NULLs as a NumPy array without copying. The schema with its types arrives before the first row, decimals with their precision and scale, timestamptz as UTC time, so a BI tool doesn’t have to guess types from text. The drivers are standard ones: ADBC Flight SQL for Python, Go, Java and C, and Flight SQL JDBC for anything that takes a JDBC driver. The way back is columnar too: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;adbc_ingest()&lt;/code&gt; writes a DataFrame straight into a table through VecInsert. Flight served a million lineitem rows 4.7× faster than binary COPY. Flight SQL only complements PostgreSQL’s familiar pgwire clients; it doesn’t replace them.&lt;/p&gt;

&lt;figure class=&quot;diagram wide&quot;&gt;
  &lt;a href=&quot;https://igor-suhorukov.github.io/blog/assets/posts/vectorized-postgresql-extension/flight-sql.png&quot;&gt;&lt;img src=&quot;https://igor-suhorukov.github.io/blog/assets/posts/vectorized-postgresql-extension/flight-sql.png&quot; width=&quot;2149&quot; height=&quot;2477&quot; alt=&quot;Sequence diagram of a query and an insert through vexec_flight. The client (ADBC Flight SQL, JDBC, pyarrow) connects to TCP port 32010; the acceptor, a background worker without threads, calls RegisterDynamicBackgroundWorker(token), the postmaster forks the session, a dynamic background worker with one thread, and the acceptor passes it the client&amp;#x27;s socket over a UNIX socket (SCM_RIGHTS). Client and session speak TLS (ALPN h2) or plaintext and HTTP/2. On the Handshake (authorization: Basic, database), the session sends the login, client address and TLS to the acceptor, which checks pg_hba.conf, the password, rolcanlogin, rolvaliduntil and connection limits and returns the role and database; the session calls BackgroundWorkerInitializeConnectionByOid() and returns a bearer token. Query: on GetFlightInfo(CommandStatementQuery) the session asks vexec whether vexec.mode is auto or force and answers FAILED_PRECONDITION if vexec is off; otherwise it parses and analyzes the statement into a saved plan source and returns FlightInfo with the Arrow schema and a ticket. On DoGet(ticket) the executor runs the plan from the plan cache in a portal with the egress API&amp;#x27;s DestReceiver; ExecutorRun_hook hands vexec the top Vec node&amp;#x27;s batches without rows (a row node&amp;#x27;s rows are gathered into batches), the IPC codec builds Schema, then RecordBatch, as pieces pointing into the batch&amp;#x27;s buffers, and the session sends them as gRPC in HTTP/2 DATA frames with writev, without copying the body, then trailers with grpc-status 0; a slow client holds the session at one batch, and nothing is spooled. Insert: DoPut(CommandStatementIngest) with a FlightData stream calls ingest_begin with the session&amp;#x27;s pull reader and runs INSERT INTO t (...) SELECT ... FROM vexec.ingest_stream(handle); for each batch VecIngest pulls the next DoPut message straight from the socket, checks it as client input (offsets, UTF-8, date ranges) and turns the Arrow buffers into a batch, and VecInsert checks NOT NULL by validity, CHECK and partitions as in COPY FROM and writes columns into a PAX or ao_column sink, otherwise through table_multi_insert() 1,000 rows at a time; PutResult returns the row count.&quot; /&gt;&lt;/a&gt;
&lt;/figure&gt;

&lt;p&gt;The process model here is PostgreSQL’s, not a service with threads: an acceptor without threads asks the postmaster to start a dynamic background worker for each connection and hands it the socket through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SCM_RIGHTS&lt;/code&gt;. The session itself runs TLS, HTTP/2 on nghttp2 and gRPC, and waits on its socket and its latch the way a backend does, so &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_cancel_backend()&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CancelFlightInfo&lt;/code&gt; work on it. A login is checked against &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_hba.conf&lt;/code&gt; with the client’s real address, the role’s password and the connection limits, and statements run through portals, so hooks, privileges, RLS, transactions and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_stat_statements&lt;/code&gt; behave as they do over PostgreSQL’s usual protocol.&lt;/p&gt;

&lt;p&gt;The result is written by vexec’s DestReceiver. If there is a vector node at the top of the plan, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExecutorRun&lt;/code&gt; hook hands over its batches without a single row, and the columns whose layout matches their Arrow type go to the socket in one &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;writev&lt;/code&gt; straight from the batch’s buffers. A slow client simply slows the session down: while a 20 GB result was read at a leisurely pace, the session’s memory grew by less than a megabyte.&lt;/p&gt;

&lt;p&gt;Inserts take the reverse path. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CommandStatementIngest&lt;/code&gt; becomes &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;INSERT INTO t ... SELECT ... FROM vexec.ingest_stream(handle)&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecIngest&lt;/code&gt; takes the next DoPut message from the socket only when the statement needs the next batch. The client’s Arrow is checked as input (offsets, UTF-8, date ranges, decimal precision), and where a column’s type matches the Arrow type, the message’s buffers become the buffers of the batch that &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecInsert&lt;/code&gt; hands to the PAX or ao_column sink.&lt;/p&gt;

&lt;h3 id=&quot;postgis-and-pgvector-functions&quot;&gt;PostGIS and pgvector functions&lt;/h3&gt;

&lt;p&gt;vexec calls other extensions’ functions through fmgr row by row, inside a vector node. But pgvector doesn’t mark its functions leakproof, and PostGIS marks only its btree comparisons and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;geometry_hash&lt;/code&gt;, so a condition with a distance or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ST_Intersects&lt;/code&gt; became lazy as a whole. A kernel pack doesn’t rewrite functions or introduce an ABI: on top of fmgr and PostgreSQL’s soft errors, it declares which functions may be called, and on which rows, earlier than PostgreSQL would call them:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;never raises:&lt;/strong&gt; the function is called on the batch’s active rows; these are PostGIS’s twelve 2-D box operators and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ST_SRID&lt;/code&gt;;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;check:&lt;/strong&gt; the function is called where the pack’s check passes, and PostgreSQL evaluates the remaining rows itself, with its own error; these are pgvector’s distances when the dimensions are equal, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ST_X&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ST_Y&lt;/code&gt; for a point;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;prefilter:&lt;/strong&gt; the pack’s answer is taken where it has one, and an undecided row (a soft error from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ereturn&lt;/code&gt;) goes to the function itself; these are eleven PostGIS predicates, by their boxes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A declaration is bound by the extension, its version, and the function’s signature and C symbol, and it is dropped on &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ALTER EXTENSION UPDATE&lt;/code&gt;. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;vexec_postgis&lt;/code&gt; reads the geometry format with its own code, written from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gserialized.txt&lt;/code&gt;, without a single line of PostGIS. pgvector’s tests (14) and PostGIS’s core tests (143) give the same answers with vexec as without it, and a server without a pack evaluates the same calls row by row: slower, but correctly.&lt;/p&gt;

&lt;h2 id=&quot;implementation-caveats&quot;&gt;Implementation caveats&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Inherited from PostgreSQL 19:
    &lt;ul&gt;
      &lt;li&gt;Gather and Gather Merge stay row-based: the tuple queue carries one MinimalTuple per row;&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ExecSetTupleBound&lt;/code&gt; doesn’t reach CustomScan, so &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VecSort&lt;/code&gt; gets its LIMIT bound at planning time;&lt;/li&gt;
      &lt;li&gt;there are no vector nodes under UPDATE, DELETE, MERGE, LockRows or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WHERE CURRENT OF&lt;/code&gt; cursors: EvalPlanQual re-reads a row into the scan node’s slot, and for heap that has to be a buffer slot.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;Compared with Cloudberry:
    &lt;ul&gt;
      &lt;li&gt;on vanilla PostgreSQL, ORCA’s plans are serial: its parallelism is switched on by gp_core’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp.enable_parallel&lt;/code&gt; setting;&lt;/li&gt;
      &lt;li&gt;a sorted Gather Motion doesn’t carry frames; it merges rows;&lt;/li&gt;
      &lt;li&gt;PAX has no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;index_delete_tuples&lt;/code&gt;, so after an aborted insert into a table with a btree index the next insert may fail, exactly as it does through ModifyTable.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;Vector-specific:
    &lt;ul&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;COUNT(DISTINCT)&lt;/code&gt; grouped by strings is 3.1–3.6× slower under PostgreSQL’s planner in auto mode: the cost model prices a serial vector plan below a parallel row plan;&lt;/li&gt;
      &lt;li&gt;text columns come out of a heap scan more slowly than out of a row scan;&lt;/li&gt;
      &lt;li&gt;force gives up index paths, except for nearest-neighbour search, so on a table with a primary key auto is faster than force.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;measurements&quot;&gt;Measurements&lt;/h2&gt;

&lt;p&gt;All the figures come from Docker images built from committed branches, on my 16-core Ryzen 9 9955HX3D with 60 GB of memory, without assertions, and every answer was checked against DuckDB’s.&lt;/p&gt;

&lt;p&gt;ClickBench was run by its own protocol on vanilla PostgreSQL 19 with ORCA, but on 10 million rows, so these figures can’t be compared with the published results on 100 million. The geometric mean of the hot-run timings of the 43 queries, in ms:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Configuration&lt;/th&gt;
      &lt;th&gt;heap&lt;/th&gt;
      &lt;th&gt;heap with a primary key&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;PostgreSQL’s planner&lt;/td&gt;
      &lt;td&gt;625&lt;/td&gt;
      &lt;td&gt;402&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;PostgreSQL’s planner + vexec, auto&lt;/td&gt;
      &lt;td&gt;506&lt;/td&gt;
      &lt;td&gt;311&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;PostgreSQL’s planner + vexec, force&lt;/td&gt;
      &lt;td&gt;498&lt;/td&gt;
      &lt;td&gt;365&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ORCA&lt;/td&gt;
      &lt;td&gt;1,647&lt;/td&gt;
      &lt;td&gt;924&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ORCA + vexec, auto&lt;/td&gt;
      &lt;td&gt;1,349&lt;/td&gt;
      &lt;td&gt;745&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;vexec takes 19–24% off PostgreSQL’s planner in auto mode and 16–19% off ORCA, and the best queries get 8.8–9.5× faster: Q15 and Q16 under PostgreSQL’s planner, Q29 under ORCA. Under PostgreSQL’s planner, Q10, Q11 and Q13 with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;COUNT(DISTINCT)&lt;/code&gt; got slower; under ORCA, Q25 and Q39, where the scan returns text columns. ORCA trails PostgreSQL’s planner by 2.3–2.6×, above all because of its serial plans.&lt;/p&gt;

&lt;p&gt;Flight SQL, TPC-H Q1 at SF1, the median of five runs from execute to an Arrow table in the client:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;vanilla PostgreSQL 19&lt;/th&gt;
      &lt;th&gt;the port’s coordinator, 2 segments&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;adbc_driver_postgresql&lt;/code&gt;, vexec off&lt;/td&gt;
      &lt;td&gt;0.743 s&lt;/td&gt;
      &lt;td&gt;1.115 s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;adbc_driver_postgresql&lt;/code&gt;, vexec auto&lt;/td&gt;
      &lt;td&gt;0.400 s&lt;/td&gt;
      &lt;td&gt;0.567 s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;adbc_driver_flightsql&lt;/code&gt;, vexec auto&lt;/td&gt;
      &lt;td&gt;0.389 s&lt;/td&gt;
      &lt;td&gt;0.555 s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;the first million lineitem rows, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;adbc_driver_postgresql&lt;/code&gt;, vexec off&lt;/td&gt;
      &lt;td&gt;0.917 s&lt;/td&gt;
      &lt;td&gt;1.012 s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;the same through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;adbc_driver_flightsql&lt;/code&gt;, vexec auto&lt;/td&gt;
      &lt;td&gt;0.194 s&lt;/td&gt;
      &lt;td&gt;0.748 s&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Q1’s time is the executor’s time, and vexec cuts it almost in half, whichever driver reads the result’s four rows. The transport shows on a million rows: Flight serves them 4.7× faster than binary COPY. On the port, the measurement was taken before V7, and the result still went through the Gather Motion as rows.&lt;/p&gt;

&lt;p&gt;Inserting 200,000 lineitem rows, the median of three loads, in rows per second:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Path&lt;/th&gt;
      &lt;th&gt;porc_vec, one node&lt;/th&gt;
      &lt;th&gt;ao_column, one node&lt;/th&gt;
      &lt;th&gt;heap, one node&lt;/th&gt;
      &lt;th&gt;porc_vec, 2 segments&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;COPY CSV from a server file&lt;/td&gt;
      &lt;td&gt;955,491&lt;/td&gt;
      &lt;td&gt;1,176,792&lt;/td&gt;
      &lt;td&gt;1,294,406&lt;/td&gt;
      &lt;td&gt;568,093&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;binary COPY through ADBC&lt;/td&gt;
      &lt;td&gt;547,494&lt;/td&gt;
      &lt;td&gt;635,661&lt;/td&gt;
      &lt;td&gt;651,960&lt;/td&gt;
      &lt;td&gt;399,412&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Flight SQL before VI: DoPut, a portal per row&lt;/td&gt;
      &lt;td&gt;27,466&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
      &lt;td&gt;-&lt;/td&gt;
      &lt;td&gt;771&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Flight SQL &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;adbc_ingest&lt;/code&gt; through VecIngest and VecInsert&lt;/td&gt;
      &lt;td&gt;1,580,626&lt;/td&gt;
      &lt;td&gt;1,826,448&lt;/td&gt;
      &lt;td&gt;1,545,523&lt;/td&gt;
      &lt;td&gt;647,592&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Into porc_vec, Flight loads 1.65× faster than COPY CSV and 2.9× faster than binary COPY. On the cluster the gain is only 1.14× so far: the stream crosses a Motion as rows and is waiting for V7’s frames.&lt;/p&gt;

&lt;p&gt;To be fair: the first TPC measurement on four segments after V2, when only scans and aggregation were vectorized, averaged out at about 1×. Q1 got 1.8–2.8× faster, but wherever a vector scan fed a row hash join, up to half the speed was lost, which is why VecHashJoin and heap’s page reader came next. Their measurement is waiting for a quiet host.&lt;/p&gt;

&lt;h2 id=&quot;the-implementation-process&quot;&gt;The implementation process&lt;/h2&gt;

&lt;p&gt;It all started with a question to Claude Code: how to build a vectorized executor inside PostgreSQL, by reusing ORCA with vectorized planning on top of Cloudberry’s columnar formats, or by adding columnar-access hooks to the core. Nine research reports went through the code of PostgreSQL 19, the Cloudberry port, PAX, AOCO, Arrow and the predecessors. The plan grew to 4,700 lines and 113,000 words; every claim in it is a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;file:lines&lt;/code&gt; reference or a number measured on this host, and its header keeps a log of my questions and of what changed after them.&lt;/p&gt;

&lt;p&gt;About fifteen of my questions changed the architecture before the first line of code was written: two batch formats, keys read from Arrow buffers, shared memory for segments on one host, Flight SQL as a separate extension, columnar inserts without core changes, kernel packs, a ClickBench measurement before implementation began. I turned down nanoarrow’s IPC: its writer copies every buffer of a batch into one body, doesn’t understand string views and allocates memory with malloc.&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Phase&lt;/th&gt;
      &lt;th&gt;Date&lt;/th&gt;
      &lt;th&gt;Result&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;VB&lt;/td&gt;
      &lt;td&gt;Oct 5&lt;/td&gt;
      &lt;td&gt;ClickBench baseline before the first line of vexec&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;V0&lt;/td&gt;
      &lt;td&gt;Oct 5&lt;/td&gt;
      &lt;td&gt;groundwork: the oracle, the cost model, two batch formats, the source contract&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;V1&lt;/td&gt;
      &lt;td&gt;Oct 5&lt;/td&gt;
      &lt;td&gt;VecScan, VecResult, the expression compiler, both planners, segments, PAX and gp_ao readers&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;V2&lt;/td&gt;
      &lt;td&gt;Oct 6&lt;/td&gt;
      &lt;td&gt;VecAgg, aggregates from PAX’s statistics, the first measurement&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;V3&lt;/td&gt;
      &lt;td&gt;Oct 6&lt;/td&gt;
      &lt;td&gt;VecHashJoin&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;V4&lt;/td&gt;
      &lt;td&gt;Oct 6&lt;/td&gt;
      &lt;td&gt;heap’s page reader, parallel paths, VecSort, VecRepartition, VecBitmapHeapScan&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;V5&lt;/td&gt;
      &lt;td&gt;Oct 6&lt;/td&gt;
      &lt;td&gt;CCostModelVec, VecWindowHashAgg&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;V6&lt;/td&gt;
      &lt;td&gt;Oct 6&lt;/td&gt;
      &lt;td&gt;ORCA on vanilla PostgreSQL 19&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;VK&lt;/td&gt;
      &lt;td&gt;Oct 6&lt;/td&gt;
      &lt;td&gt;kernel packs for pgvector and PostGIS&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;V7_0, V10&lt;/td&gt;
      &lt;td&gt;Oct 6&lt;/td&gt;
      &lt;td&gt;the Arrow IPC codec, the egress API, vexec_flight&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;VI&lt;/td&gt;
      &lt;td&gt;Oct 7&lt;/td&gt;
      &lt;td&gt;sinks, VecInsert, inserts from Flight SQL&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;V7&lt;/td&gt;
      &lt;td&gt;Oct 7&lt;/td&gt;
      &lt;td&gt;Arrow frames through Motions, the shm transport&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Here is how it went over time:&lt;/p&gt;

&lt;figure class=&quot;diagram wide&quot;&gt;
  &lt;a href=&quot;https://igor-suhorukov.github.io/blog/assets/posts/vectorized-postgresql-extension/timeline.png&quot;&gt;&lt;img src=&quot;https://igor-suhorukov.github.io/blog/assets/posts/vectorized-postgresql-extension/timeline.png&quot; width=&quot;1200&quot; height=&quot;508&quot; alt=&quot;Gantt chart of pg_vexec from the plan to a working solution, October 2–7, 2026. Plan: research, six reports and the first plan on October 2, 08:42–12:47; questions that changed the architecture and three reports on formats on the morning of October 5. Phases in sequence on October 5: VB, the ClickBench baseline, 06:13–12:31; V0 groundwork 10:37–14:01; V1 scans, filters and projections 17:07–22:01; V2 aggregation 22:20–04:18 overnight; then V3 hash join on October 6, 04:42–07:37. Parallel sessions on October 6: V4 heap, parallel paths and sorts 07:46–12:20; V6 ORCA on vanilla PostgreSQL 19 09:54–10:59; V5 CCostModelVec and VecWindowHashAgg 11:59–18:24; V7_0 and V10, the Arrow IPC codec and Flight SQL, 12:15–18:52; VK kernel packs for pgvector and PostGIS 16:36–18:29; and on October 7, VI batch inserts 05:39–07:51 and V7 batches through Motions and shm 05:55–10:51. Performance measurements: the V2 TPC measurement, 12 of 32 runs, 02:43–05:30 on October 6; Flight SQL with ADBC on TPC-H Q1 at 18:20 on October 6; ClickBench on ORCA in vanilla PostgreSQL 19 from 21:41 on October 6 to 01:55.&quot; /&gt;&lt;/a&gt;
&lt;/figure&gt;

&lt;p&gt;Each phase gets its own branch and its own worktree, and a commit happens only after I approve it, after which the task statuses in the plan are updated. On October 6, V4, V5, V6, VK and V10 were built by parallel sessions, while the Cloudberry port’s session took the defects they found in Cloudberry and fixed them in its own session: there were about ten, in PAX, gp_core and ORCA, plus one more in PostGIS 3.7.0rc2. The sessions take turns with the 40 GB runs: two of them side by side once hung the host.&lt;/p&gt;

&lt;p&gt;There were surprises this time too:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;A test that passes without the fix.&lt;/strong&gt; Since V1, the boolean register’s type had lived in the statement’s memory, and the next statement read freed memory. In a single query string the check passed, because an identical type took up the same memory, but sent as separate queries it crashed the backend every time. Now every regression check is first run without its fix.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Unknown geometry type: 2139062143&lt;/code&gt;.&lt;/strong&gt; That’s 0x7F7F7F7F, a debug build’s freed memory: vexec evaluated a lazy target in the query’s memory, that is, in the functions’ &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fn_mcxt&lt;/code&gt;, and PostGIS’s caches considered that memory their own. PostGIS’s own test caught it;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ORCA pruned the best plan.&lt;/strong&gt; ORCA uses a partial plan as a lower bound for pruning, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CCostModelVec&lt;/code&gt; costed it above the plans it bounds, so ORCA threw away cheaper aggregations and join orders;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;OID 7100.&lt;/strong&gt; ORCA hard-codes Cloudberry’s hash OIDs as constants, while gp_core assigns them at installation, so a GROUP BY on a table keyed by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cdbhash_int4_ops&lt;/code&gt; failed with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;could not find hash function for type 23 in operator family 7100&lt;/code&gt;. I ran into it while implementing Arrow data transfer through Motions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;the-outcome&quot;&gt;The outcome&lt;/h2&gt;

&lt;p&gt;In three days of implementation I built a vectorized query executor and planner as a PostgreSQL 19 extension that:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;runs on a vanilla server without a single core patch, ORCA included, and goes back to vanilla PostgreSQL behaviour with one setting;&lt;/li&gt;
  &lt;li&gt;turns ORCA into a vectorized engine, on a single node just as on every segment of an MPP cluster;&lt;/li&gt;
  &lt;li&gt;moves data in columnar form from PAX and ao_column into the executor’s memory and back without needless serialization and deserialization, and passes batches between segments as Arrow frames;&lt;/li&gt;
  &lt;li&gt;serves and accepts data as Arrow through Flight SQL, straight from the planner’s and the storage’s data structures;&lt;/li&gt;
  &lt;li&gt;processes PostGIS and pgvector data types with their own functions over columnar buffers in the query executor’s memory;&lt;/li&gt;
  &lt;li&gt;behaves like PostgreSQL: 239 regression tests pass without edits to the expected files, and the TPC and ClickBench answers match DuckDB’s reference answers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s about 43,000 lines of C in vexec, 7,900 in vexec_flight, a thousand in the kernel packs and about 13,600 in the Cloudberry port’s modules in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg19/&lt;/code&gt;, the shm transport and copies of PAX files included. Next up are further optimizations and performance measurements, and running ClickBench on columnar tables in Cloudberry. Of course the project still has room to grow and things to optimize, but even at this stage it is far more than a prototype for speeding up PostgreSQL. The result is an open-source database that integrates better with the AI/ML ecosystem by speaking Apache Arrow over Flight SQL to the outside world, while still supporting the existing PostgreSQL drivers.&lt;/p&gt;

&lt;p&gt;In a few days of implementation I proved that vectorizing query execution in PostgreSQL doesn’t need a fork. PostgreSQL 19 already has everything needed to plan vector nodes inside the planner rather than patch plans after the planning phase. And PostgreSQL’s semantics were preserved without sacrificing query speed. ORCA, inherited from Greenplum, now plans vector nodes even on vanilla PostgreSQL, without gp_core and without a single core patch. A modular approach in an open-source database is worth far more than walling a fork off behind a high barrier to entry and the exclusivity of its maintenance. I believe that in the long run, modularity and an open ecosystem pay off more for the developer community and for those who run PostgreSQL themselves.&lt;/p&gt;

&lt;p&gt;The results are available here: &lt;a href=&quot;https://github.com/igor-suhorukov/pg_vexec&quot;&gt;pg_vexec (vexec)&lt;/a&gt;, &lt;a href=&quot;https://github.com/igor-suhorukov/pg_vexec/tree/main/modules/vexec_flight&quot;&gt;pg_vexec_flight&lt;/a&gt;, &lt;a href=&quot;https://github.com/igor-suhorukov/pg_vexec/tree/main/modules/vexec_pgvector&quot;&gt;pg_vexec_pgvector&lt;/a&gt; and &lt;a href=&quot;https://github.com/igor-suhorukov/pg_vexec/tree/main/modules/vexec_postgis&quot;&gt;pg_vexec_postgis&lt;/a&gt;; &lt;a href=&quot;https://github.com/igor-suhorukov/cloudberry/tree/extension_postgresql_19/pg19&quot;&gt;the Cloudberry port’s changes needed for vectorization are in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg19/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;</content>
  </entry>
  <entry>
    <title type="html">Greenplum without the fork: Apache Cloudberry as PostgreSQL 19 Extensions</title>
    <link href="https://igor-suhorukov.github.io/blog/2026/09/30/greenplum-without-the-fork/" rel="alternate" type="text/html"/>
    <id>https://igor-suhorukov.github.io/blog/2026/09/30/greenplum-without-the-fork</id>
    <published>2026-09-30T00:00:00+00:00</published>
    <updated>2026-09-30T00:00:00+00:00</updated>
    <author>
      <name>Igor Suhorukov</name>
    </author>
    <category term="postgresql"/>
    <category term="greenplum"/>
    <category term="cloudberry"/>
    <category term="mpp"/>
    <summary type="html">A personal experiment: Apache Cloudberry, the most up-to-date open-source version of Greenplum, turned into a set of extensions for PostgreSQL 19. Plans are cut into slices, rows travel between segments, and transactions use distributed snapshots and two-phase commit, while PostgreSQL itself needed only 22 small patches that add new extension points.</summary>
    <content type="html" xml:base="https://igor-suhorukov.github.io/blog/2026/09/30/greenplum-without-the-fork/">&lt;blockquote&gt;
  &lt;p&gt;This is my personal experiment, not an official Apache Cloudberry release.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Since 2017 I have been keeping an eye on the PostgreSQL ecosystem for analytics and data warehousing. At work I had used AWS Redshift, with all the drawbacks and inconveniences a developer gets from a fork of an old PostgreSQL. To my mind, the ideal PostgreSQL-based solution should run on the latest PostgreSQL release, be built as an extension, be easy to start locally in a container for tests, and work with the latest versions of existing extensions such as PostGIS and pgvector.&lt;/p&gt;

&lt;p&gt;Citus came closest to what I wanted, but I wouldn’t call it a full-fledged massively parallel processing (MPP) system. It is more about sharding data for OLTP workloads.&lt;/p&gt;

&lt;p&gt;Greenplum, an MPP fork of PostgreSQL, has a lot going for it, and plenty of people run it for real work, but the PostgreSQL it is built on lags behind the current one. So I set out to turn Apache Cloudberry, the most up-to-date open-source version of Greenplum, into a set of extensions for PostgreSQL 19. For now this is my personal experiment, not an official Apache Cloudberry release. With an LLM’s help, more than 90% of Cloudberry’s database code has been ported, and 59% of the original project’s tests now run on the new version. It took me 12 days…&lt;/p&gt;

&lt;p&gt;Greenplum has always trailed PostgreSQL by a few releases. Greenplum 6 shipped on PostgreSQL 9.4 and Greenplum 7 on 12, and Apache Cloudberry, which carries the Greenplum project on, got its main branch up to 16.9 only in May 2026. The reason is that this massively parallel database is a fork: Cloudberry has changed 882 of PostgreSQL’s source files and added 646 new ones, not counting the 1.66 million lines of its ORCA optimizer, test data included. Every new PostgreSQL major version means months of merging, and by the time the developers finish, upstream has moved on.&lt;/p&gt;

&lt;p&gt;I decided to go the other way around and cross a hedgehog with a snake, there is a saying: instead of pouring PostgreSQL’s sources into Greenplum, fit Cloudberry on top of PostgreSQL 19 as a set of extensions. The most important principle was that PostgreSQL 19 may get a few small changes for new extension points, but a server with my core patches and without Cloudberry’s extensions loaded must be indistinguishable from vanilla PostgreSQL 19, both to its users and to other extensions, and must perform almost exactly like the unmodified core, within a fraction of a percent.&lt;/p&gt;

&lt;p&gt;Claude Code wrote the code for me, in many sessions, with agents working in parallel git worktrees. I discussed the initial plan with the LLM, made the architectural decisions and checked the results. The last time I wrote serious C/C++ was almost 20 years ago, and yet I consider this experiment a success!&lt;/p&gt;

&lt;p&gt;All told, it took 12 days and 629 commits to get an MPP engine that works like the Greenplum fork, not just another FDW that drags all the data to the coordinator. Plans are cut into slices, rows travel between segments while a query runs, and transactions use distributed snapshots and two-phase commit. PostgreSQL itself needed only 22 small patches that add new extension points. (I confess there were 28 at first; I brought the number down later, at the cost of a few compromises.)&lt;/p&gt;

&lt;h2 id=&quot;tldr&quot;&gt;TL;DR&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;What&lt;/th&gt;
      &lt;th&gt;Number&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;PostgreSQL 19 core changes&lt;/td&gt;
      &lt;td&gt;22 commits, 47 files, +1,578/−41 lines&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;The port&lt;/td&gt;
      &lt;td&gt;one new directory, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg19/&lt;/code&gt;: 1,153 files, +432,978 lines. Zero lines changed in Cloudberry’s own files&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ORCA&lt;/td&gt;
      &lt;td&gt;920 core files (~380k lines) compiled from Cloudberry’s tree unmodified&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloudberry database code ported&lt;/td&gt;
      &lt;td&gt;≈93% by lines of code&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloudberry’s own tests passing&lt;/td&gt;
      &lt;td&gt;691 of the 1,180 test files in its schedules (59%)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;TPC-H + TPC-DS, scale factor 1&lt;/td&gt;
      &lt;td&gt;ORCA plans all 121 queries; answers match DuckDB’s; 3.1× and 4.0× faster than the non-ORCA fallback&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cost of the dormant hooks&lt;/td&gt;
      &lt;td&gt;−0.005% to +0.155% executed instructions&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Code: &lt;a href=&quot;https://github.com/igor-suhorukov/postgres/tree/REL_19_STABLE_CLOUDBERRY&quot;&gt;core series&lt;/a&gt;, &lt;a href=&quot;https://github.com/igor-suhorukov/cloudberry/tree/extension_postgresql_19/pg19&quot;&gt;port&lt;/a&gt;, &lt;a href=&quot;https://github.com/igor-suhorukov/cloudberry/tree/extension_postgresql_19/pg19#building&quot;&gt;build instructions&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;why-an-extension&quot;&gt;Why an extension&lt;/h2&gt;

&lt;p&gt;Pivotal open-sourced Greenplum more than a decade ago, in 2015, but some time after Broadcom bought VMware, the project’s repositories were archived. The same code now lives on in forks: open-gpdb, EDB’s WarehousePG and Apache Cloudberry. Cloudberry is based on Greenplum 7 and entered the Apache Incubator. PostgreSQL 19 isn’t stable yet: it is at Beta 4, with a release candidate expected any week now.&lt;/p&gt;

&lt;p&gt;How big is the thing being ported? &lt;a href=&quot;https://sonarcloud.io/summary/overall?id=apache_cloudberry&amp;amp;branch=main&quot;&gt;SonarCloud&lt;/a&gt; counts 2.6M lines of code on Apache Cloudberry’s main branch (analysis of September 28, 2026, commit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3f4c688&lt;/code&gt;). That is 4.0M lines with comments and blank lines, in 10,189 files:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Language&lt;/th&gt;
      &lt;th&gt;Lines of code&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;C&lt;/td&gt;
      &lt;td&gt;1,396,894&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;SQL (SonarCloud’s PL/SQL analyzer)&lt;/td&gt;
      &lt;td&gt;731,180&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;C++&lt;/td&gt;
      &lt;td&gt;337,098&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;XML&lt;/td&gt;
      &lt;td&gt;82,263&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Python&lt;/td&gt;
      &lt;td&gt;65,961&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Shell&lt;/td&gt;
      &lt;td&gt;7,310&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;YAML&lt;/td&gt;
      &lt;td&gt;6,670&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;HTML, CSS, JSON, Docker, other&lt;/td&gt;
      &lt;td&gt;1,869&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;2,629,245&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Nine-tenths of the SQL is tests: 438k lines in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;src/test&lt;/code&gt; and 218k in PAX’s test suites. The count covers the whole repository, tests and tools included, so it is not the base of the ≈93% figure, which weighs the database code module by module.&lt;/p&gt;

&lt;p&gt;The main advantage of an extension is that you get the latest PostgreSQL and almost every tool in its ecosystem unmodified, as ready-built packages. Moving to the next major version is also far less painful than it is for a fork: rebasing means carrying 22 small patches, about 1,600 lines in total, over to the new release, not months of merging.&lt;/p&gt;

&lt;p&gt;For current Greenplum and Cloudberry users, the extension brings:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A current PostgreSQL, from MERGE and the latest SQL/JSON goodies to asynchronous I/O and parallel autovacuum.&lt;/li&gt;
  &lt;li&gt;Community extensions. The nodes can run upstream PostGIS 3.7 instead of Cloudberry’s fork of PostGIS 3.3.2, and pgvector 0.8.6 runs unpatched. Binary modules built for vanilla PostgreSQL 19 load as they are, because the core’s ABI hasn’t changed.&lt;/li&gt;
  &lt;li&gt;The familiar bits: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DISTRIBUTED BY&lt;/code&gt; and the classic &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PARTITION BY ... EVERY&lt;/code&gt;, gpfdist, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_toolkit&lt;/code&gt;, gpMgmt’s tools from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpinitsystem&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpexpand&lt;/code&gt;, and EXPLAIN output with Motion nodes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no free lunch, of course. You pay with a logical migration (dump and restore) instead of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_upgrade&lt;/code&gt;. The 438 configuration settings are renamed with a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp.&lt;/code&gt; prefix, although &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpconfig&lt;/code&gt; still accepts the old names. Much of Cloudberry’s DDL is translated under the hood into standard PostgreSQL statements, and that is what command tags and dumps show. And database-level encryption gives way, for now, to volume encryption in the operating system.&lt;/p&gt;

&lt;p&gt;For newcomers to Greenplum, the extension means:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The patched PostgreSQL looks like any ordinary PostgreSQL as long as Cloudberry isn’t loaded into it.&lt;/li&gt;
  &lt;li&gt;Even on a single node, loading the modules gives you incremental materialized views, a task scheduler, append-optimized tables and PAX columnar storage, and, most useful of all, the ORCA optimizer.&lt;/li&gt;
  &lt;li&gt;Going MPP on a cluster doesn’t mean trading the PostgreSQL you know for another ecosystem, other drivers or other BI tools.&lt;/li&gt;
  &lt;li&gt;Your PL/pgSQL, PostGIS and pgvector stay with you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;cloudberry-as-a-postgresql-compatible-mpp-warehouse&quot;&gt;Cloudberry as a PostgreSQL-compatible MPP warehouse&lt;/h2&gt;

&lt;p&gt;A cost-based distributed optimizer plans each query. The plan itself, serialized, is shipped to the nodes and runs on all segments at once, and the segments pass the data they need to each other through Motions. Through all of this, transactions stay ACID across the whole cluster, and compressed columnar storage is there when you want it. On top of it all sit workload management and failover.&lt;/p&gt;

&lt;p&gt;The typical use is a data warehouse of tens or hundreds of terabytes in star and snowflake schemas, with reports that join many large tables. It is also often used to run ELT in SQL and PL/pgSQL.&lt;/p&gt;

&lt;figure class=&quot;diagram wide&quot;&gt;
  &lt;a href=&quot;https://igor-suhorukov.github.io/blog/assets/posts/greenplum-without-the-fork/landscape.png&quot;&gt;&lt;img src=&quot;https://igor-suhorukov.github.io/blog/assets/posts/greenplum-without-the-fork/landscape.png&quot; width=&quot;1249&quot; height=&quot;624&quot; alt=&quot;Mind map of analytics on PostgreSQL data. Scale out to many nodes: Apache Cloudberry, this port (MPP with one plan per cluster, Motions between segments, distributed snapshots, ORCA) and Citus (sharding, SQL fragments per shard, 2PC without a global snapshot). Faster on one node: pg_duckdb (DuckDB&amp;#x27;s vectorized engine inside the backend) and PG-Strom (GPUs and GPUDirect SQL). Data in the lake, Parquet and Iceberg: pg_lake (Iceberg tables, DuckDB in a sidecar process) and pg_mooncake (columnar mirrors in Iceberg). Separate OLAP engines: ClickHouse (vectorized columnar, its own SQL dialect) and StarRocks (vectorized MPP, Cascades CBO, MySQL protocol).&quot; /&gt;&lt;/a&gt;
&lt;/figure&gt;

&lt;p&gt;Here is how it compares with PostgreSQL extensions as of September 2026:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;My Cloudberry port&lt;/th&gt;
      &lt;th&gt;Citus 14.2&lt;/th&gt;
      &lt;th&gt;pg_duckdb 1.1.1&lt;/th&gt;
      &lt;th&gt;pg_lake 3.5.3&lt;/th&gt;
      &lt;th&gt;PG-Strom 6.1&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Scale&lt;/td&gt;
      &lt;td&gt;cluster of segments&lt;/td&gt;
      &lt;td&gt;cluster of shards&lt;/td&gt;
      &lt;td&gt;one node&lt;/td&gt;
      &lt;td&gt;one node + DuckDB in a sidecar process&lt;/td&gt;
      &lt;td&gt;one GPU server&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Execution&lt;/td&gt;
      &lt;td&gt;PostgreSQL executor; plan slices on every segment, Motions between them&lt;/td&gt;
      &lt;td&gt;PostgreSQL executor; SQL fragments per shard&lt;/td&gt;
      &lt;td&gt;vectorized DuckDB in each backend&lt;/td&gt;
      &lt;td&gt;vectorized DuckDB in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pgduck_server&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;GPU code generated from SQL&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Large joins&lt;/td&gt;
      &lt;td&gt;ORCA, cost-based distributed plans&lt;/td&gt;
      &lt;td&gt;join order “relatively naive” (its docs); repartition joins off by default&lt;/td&gt;
      &lt;td&gt;one machine&lt;/td&gt;
      &lt;td&gt;pushed to DuckDB, otherwise run by PostgreSQL&lt;/td&gt;
      &lt;td&gt;inner side must fit in GPU memory&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Consistency&lt;/td&gt;
      &lt;td&gt;2PC + distributed snapshots&lt;/td&gt;
      &lt;td&gt;2PC, no global snapshot&lt;/td&gt;
      &lt;td&gt;no transaction writing to both PostgreSQL and DuckDB tables&lt;/td&gt;
      &lt;td&gt;Iceberg UPDATE/DELETE run one at a time&lt;/td&gt;
      &lt;td&gt;PostgreSQL’s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Core&lt;/td&gt;
      &lt;td&gt;22 core patches&lt;/td&gt;
      &lt;td&gt;stock&lt;/td&gt;
      &lt;td&gt;stock&lt;/td&gt;
      &lt;td&gt;stock&lt;/td&gt;
      &lt;td&gt;stock&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;pg_mooncake belongs to the same single-node lakehouse family, but Databricks bought Mooncake Labs in October 2025 and version 0.2 never shipped.&lt;/p&gt;

&lt;p&gt;Of all of these, only my Cloudberry port runs a complex query over large tables as a single distributed, pipelined plan in which every node reads the same distributed snapshot. It also lets you keep PostGIS, pgvector and other types in its columnar tables and run UPDATE and MERGE on them. Where it loses, for now, is speed per CPU core: PostgreSQL’s executor is an engine built around rows in memory. And in its current form the extension is unlikely to make it into managed PostgreSQL services: it needs a patched core, and the likes of AWS and Azure won’t go for that.&lt;/p&gt;

&lt;p&gt;Among the popular open-source engines for analytical queries, ClickHouse 26.9 and StarRocks 4.1.5 are entirely different beasts, with vectorized columnar executors. StarRocks has its own Cascades optimizer and lakehouse catalogs, while its multi-statement transactions are still in beta. ClickHouse has its own SQL dialect and experimental transactions.&lt;/p&gt;

&lt;p&gt;ClickBench shows the gap between the executors: one flat table of 100 million rows, 43 queries, no joins, on a c6a.4xlarge machine. Each figure is the total over all 43 queries of the hot run, meaning the better of runs 2 and 3, &lt;a href=&quot;https://github.com/ClickHouse/ClickBench/blob/ad5c59b02435baff108efd7da3c9f53c422b199a/README.md?plain=1#L220-L222&quot;&gt;as ClickBench defines it&lt;/a&gt;; each column header links to its published result file:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;&lt;a href=&quot;https://github.com/ClickHouse/ClickBench/blob/ad5c59b02435baff108efd7da3c9f53c422b199a/clickhouse/results/20260928/c6a.4xlarge.json&quot;&gt;ClickHouse&lt;/a&gt;&lt;/th&gt;
      &lt;th&gt;&lt;a href=&quot;https://github.com/ClickHouse/ClickBench/blob/ad5c59b02435baff108efd7da3c9f53c422b199a/duckdb/results/20260511/c6a.4xlarge.json&quot;&gt;DuckDB&lt;/a&gt;&lt;/th&gt;
      &lt;th&gt;&lt;a href=&quot;https://github.com/ClickHouse/ClickBench/blob/ad5c59b02435baff108efd7da3c9f53c422b199a/starrocks/results/20260907/c6a.4xlarge.json&quot;&gt;StarRocks&lt;/a&gt;&lt;/th&gt;
      &lt;th&gt;&lt;a href=&quot;https://github.com/ClickHouse/ClickBench/blob/ad5c59b02435baff108efd7da3c9f53c422b199a/pg_duckdb-parquet/results/20260511/c6a.4xlarge.json&quot;&gt;pg_duckdb (Parquet)&lt;/a&gt;&lt;/th&gt;
      &lt;th&gt;&lt;a href=&quot;https://github.com/ClickHouse/ClickBench/blob/ad5c59b02435baff108efd7da3c9f53c422b199a/cloudberry/results/20260517/c6a.4xlarge.json&quot;&gt;Cloudberry 1.5.3 (AOCO)&lt;/a&gt;&lt;/th&gt;
      &lt;th&gt;&lt;a href=&quot;https://github.com/ClickHouse/ClickBench/blob/ad5c59b02435baff108efd7da3c9f53c422b199a/greenplum/results/20260517/c6a.4xlarge.json&quot;&gt;Greenplum 7.1 (AOCO)&lt;/a&gt;&lt;/th&gt;
      &lt;th&gt;&lt;a href=&quot;https://github.com/ClickHouse/ClickBench/blob/ad5c59b02435baff108efd7da3c9f53c422b199a/citus/results/20260510/c6a.4xlarge.json&quot;&gt;Citus columnar&lt;/a&gt;&lt;/th&gt;
      &lt;th&gt;&lt;a href=&quot;https://github.com/ClickHouse/ClickBench/blob/ad5c59b02435baff108efd7da3c9f53c422b199a/postgresql/results/20260921/c6a.4xlarge.json&quot;&gt;PostgreSQL heap&lt;/a&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;17.6 s&lt;/td&gt;
      &lt;td&gt;26.3 s&lt;/td&gt;
      &lt;td&gt;44.5 s&lt;/td&gt;
      &lt;td&gt;52.5 s&lt;/td&gt;
      &lt;td&gt;294.7 s&lt;/td&gt;
      &lt;td&gt;768.6 s&lt;/td&gt;
      &lt;td&gt;1,745.1 s&lt;/td&gt;
      &lt;td&gt;11,875.4 s&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Source data: the &lt;a href=&quot;https://github.com/ClickHouse/ClickBench&quot;&gt;ClickBench repository&lt;/a&gt; at commit &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ad5c59b&lt;/code&gt; (2026-09-28); the same results are charted at &lt;a href=&quot;https://benchmark.clickhouse.com/&quot;&gt;benchmark.clickhouse.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;On scans and aggregates the vectorized engines are an order of magnitude ahead, because Cloudberry’s executor processes rows in memory without vectorization, and so does my port’s, which is simply PostgreSQL’s. But this benchmark has no joins of many large tables, and that is exactly where MPP engines justify their architecture and optimizations.&lt;/p&gt;

&lt;p&gt;So my rule of thumb is:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Cloudberry for complex SQL over large tables with transactions, PL/pgSQL, PostGIS and pgvector, on your own servers;&lt;/li&gt;
  &lt;li&gt;Citus for multi-tenant workloads routed by key;&lt;/li&gt;
  &lt;li&gt;pg_duckdb and pg_lake for single-node speed and data in Iceberg;&lt;/li&gt;
  &lt;li&gt;ClickHouse for logs and events, where it shines;&lt;/li&gt;
  &lt;li&gt;StarRocks for joins combined with lakehouse elasticity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;the-ground-rules&quot;&gt;The ground rules&lt;/h2&gt;

&lt;p&gt;It all started with a plan. My two prompts to Claude Opus 5 were “research cloudberry project directory and explore how to use hooks and macros to move it with minimal code changes to PostgreSQL v19” and “Plan the approach so that modifications to the PostgreSQL core—made without the Greenplum extension loaded—do not alter the operation or functionality of the PostgreSQL server.” The result was six rules that the coding agents followed strictly:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Use PostgreSQL’s existing extension points first: hooks, table access methods, CustomScan, custom WAL resource managers, background workers, security labels, FDWs.&lt;/li&gt;
  &lt;li&gt;Where they aren’t enough, add a minimal hook of your own. Here, a hook of your own means a function pointer that is NULL by default, an exported function, a registration list or a flag that is off by default. No existing signature changes, no existing struct gains a field, and the catalog, WAL, page and protocol formats stay as they are.&lt;/li&gt;
  &lt;li&gt;Without the extension loaded, the server behaves like vanilla PostgreSQL.&lt;/li&gt;
  &lt;li&gt;Cloudberry’s existing code isn’t edited. The whole port is one new directory, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg19/&lt;/code&gt;, and the upstream project’s files are left alone, which keeps merges simple. If a file does have to change, a copy of it goes into the extension’s directory with a note on what was modified.&lt;/li&gt;
  &lt;li&gt;ORCA sits behind &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;planner_hook&lt;/code&gt;. What it can’t handle is rewritten in the query before it gets there; the optimizer itself is not patched.&lt;/li&gt;
  &lt;li&gt;Third-party extensions stay as upstream ships them, unedited. Where an adaptation is needed, it lives in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg19/&lt;/code&gt;, as it did for a couple of PostGIS features.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Vanilla behaviour is checked by tests that compare two Docker images built from the same PostgreSQL commit, one with the patch series and one without:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;PostgreSQL’s own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;meson test&lt;/code&gt;: the same 371 tests pass;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;abidiff&lt;/code&gt;: 32 symbols added, 0 changed;&lt;/li&gt;
  &lt;li&gt;catalogs, settings, keywords and stored parse trees: byte-identical;&lt;/li&gt;
  &lt;li&gt;query IDs: identical;&lt;/li&gt;
  &lt;li&gt;data directories and streaming replication: interchangeable in both directions;&lt;/li&gt;
  &lt;li&gt;cachegrind: from −0.005% to +0.155% instructions, against a 0.5% failure threshold (&lt;a href=&quot;https://github.com/igor-suhorukov/cloudberry/blob/16b1ef30668f13423f44762eaeca3a252dc52319/pg19/test/vanilla/checks/09-performance.sh&quot;&gt;the check&lt;/a&gt;);&lt;/li&gt;
  &lt;li&gt;a script checks that every statement added inside an existing function sits behind a hook, a registry or a flag; there is one documented exception;&lt;/li&gt;
  &lt;li&gt;a probe module installs every hook and checks what it does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The checks were worth it: the very first test of the new combo-CID hook crashed the server.&lt;/p&gt;

&lt;h3 id=&quot;where-the-existing-hooks-fell-short-22-new-ones&quot;&gt;Where the existing hooks fell short: 22 new ones&lt;/h3&gt;

&lt;p&gt;On a completely unmodified core, Greenplum could only work as a different product: segments would receive SQL text instead of ready-made plans, and each would have its own OIDs and snapshots, the way Citus does it. An MPP executor needs the whole cluster to share one plan and one transaction. PostgreSQL 19 has no extension points for that, so I had to cobble together new core patches:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Need&lt;/th&gt;
      &lt;th&gt;Patches&lt;/th&gt;
      &lt;th&gt;Why no existing hook works&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Identical OIDs on every node&lt;/td&gt;
      &lt;td&gt;R1 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;new_oid_hook&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;OID allocation has no hook&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;All slices at once, readers seeing the writer’s uncommitted rows&lt;/td&gt;
      &lt;td&gt;R2 combo-CID hooks, R4 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;XactAdoptTransactionState()&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;only PostgreSQL’s own parallel workers share a transaction&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2PC in Cloudberry’s order; table locks without upgrade deadlocks&lt;/td&gt;
      &lt;td&gt;O33, O30&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;XACT_EVENT_COMMIT&lt;/code&gt; fires once the transaction is already visible; the parser picks lock modes first&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloudberry’s syntax, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_segment_id&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT *&lt;/code&gt; hiding matview counters&lt;/td&gt;
      &lt;td&gt;O26, O10, O31, O28&lt;/td&gt;
      &lt;td&gt;there is no parser hook; system columns change catalogs&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Distributed ANALYZE, incremental matviews&lt;/td&gt;
      &lt;td&gt;O3, O27&lt;/td&gt;
      &lt;td&gt;heap sampling is hard-wired; maintenance mode is static&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Append-optimized and PAX storage, diskquota, checksums&lt;/td&gt;
      &lt;td&gt;O13–O18, O21, O23&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TableAmRoutine&lt;/code&gt; cannot grow without breaking the ABI; the storage manager has no hooks&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Tablespaces per node, memory protection, fault tests&lt;/td&gt;
      &lt;td&gt;O32, O25, O29&lt;/td&gt;
      &lt;td&gt;redo has fixed paths built in; memory-context methods are &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;static const&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;At first the core got 28 new commits. Later, after a careful look at what could be removed or replaced, five patches moved into the modules (the query cancel while waiting for synchronous replication, node labels in EXPLAIN, the size functions, UPDATE’s old row version and the unlink hook), and two more were merged into one. The main compromise: full-table UPDATEs on PAX got 70% slower, though UPDATEs on append-optimized tables got 10–25% faster.&lt;/p&gt;

&lt;p&gt;Four of the new hooks are general enough to propose upstream: the table AM registry, smgr file events, the parser hook and the OID hook.&lt;/p&gt;

&lt;p&gt;Changes to formats and the protocol, however, can’t be carried by any custom hook:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;private libpq messages;&lt;/li&gt;
  &lt;li&gt;20 shared catalogs;&lt;/li&gt;
  &lt;li&gt;81 keywords;&lt;/li&gt;
  &lt;li&gt;217 fixed OIDs;&lt;/li&gt;
  &lt;li&gt;cluster-wide TDE.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Greenplum’s metadata moved into PostgreSQL’s security labels and extension tables, and instead of TDE I decided that, for now, it is better to encrypt the volume at the operating-system level. With somewhat deeper core changes, though, TDE could be made to work exactly as it does in Cloudberry.&lt;/p&gt;

&lt;h2 id=&quot;architecture&quot;&gt;Architecture&lt;/h2&gt;

&lt;p&gt;From the outside it is Greenplum: a coordinator that holds the catalog and plans queries, and segments on other hosts, each a full PostgreSQL 19 instance with its own share of the rows. The only new thing is how it is all wired together.&lt;/p&gt;

&lt;figure class=&quot;diagram wide&quot;&gt;
  &lt;a href=&quot;https://igor-suhorukov.github.io/blog/assets/posts/greenplum-without-the-fork/architecture.png&quot;&gt;&lt;img src=&quot;https://igor-suhorukov.github.io/blog/assets/posts/greenplum-without-the-fork/architecture.png&quot; width=&quot;1322&quot; height=&quot;1788&quot; alt=&quot;Architecture diagram. psql, BI tools and drivers, the gpMgmt tools and two gpfts instances with etcd talk to gp_core; gpfdist, s3:// and PXF feed the external-data modules. The port&amp;#x27;s modules in pg19/ are the MPP core (gp_core: dispatcher, interconnect, 2PC, distributed snapshots, FTS, GDD; gp_orca: ORCA and its translator; gp_sql: Cloudberry syntax, tags, directory tables) and storage and services (gp_ao and pax; gp_exttable, pxf_fdw, gpcloud, datalake_fdw; gp_resource, gp_matview, gp_task, gp_security, diskquota). They plug into PostgreSQL 19&amp;#x27;s standard extension points and its 22 dormant hooks, as do stock PostGIS 3.7 and pgvector 0.8.6, whose scripts and settings gp_core handles on every node. gp_orca compiles ORCA&amp;#x27;s core (920 files, about 380k lines) in place, and the storage and service modules use Cloudberry&amp;#x27;s unmodified PAX, UDP2, GDD, cgroups, vmem tracker and etcd client sources.&quot; /&gt;&lt;/a&gt;
&lt;/figure&gt;

&lt;figure class=&quot;diagram wide&quot;&gt;
  &lt;a href=&quot;https://igor-suhorukov.github.io/blog/assets/posts/greenplum-without-the-fork/query-flow.png&quot;&gt;&lt;img src=&quot;https://igor-suhorukov.github.io/blog/assets/posts/greenplum-without-the-fork/query-flow.png&quot; width=&quot;1871&quot; height=&quot;1518&quot; alt=&quot;Sequence diagram of a query. The coordinator backend passes the SQL text to gp_sql through raw_parser_hook, which rewrites Cloudberry&amp;#x27;s spelling into PostgreSQL 19&amp;#x27;s and calls standard_raw_parser(). The Query goes to gp_orca through planner_hook: a pre-ORCA rewrite (PostGIS index conditions), then Query → DXL → ORCA → PlannedStmt with slices and Motion nodes. If ORCA declines, the reason is counted and standard_planner() with gp_core&amp;#x27;s gathers takes over (the gather route). gp_core&amp;#x27;s ExecutorStart and ExecutorRun hooks send each slice over libpq, with SET LOCAL gp.distributed_snapshot and SELECT gp_internal.exec_fragment(slice, key), to the segment writer and to the readers, one gang per slice. The readers adopt the writer&amp;#x27;s transaction state via DSM (XactAdoptTransactionState(), R4) and its lock group, exchange rows through Redistribute and Broadcast Motions over the interconnect and send rows for the writer&amp;#x27;s slice; a Gather Motion (binary cursor, batched) returns the tuples to the coordinator, which returns the result to the client.&quot; /&gt;&lt;/a&gt;
&lt;/figure&gt;

&lt;p&gt;SQL parsing works without a forked grammar (Cloudberry forks it). The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_sql&lt;/code&gt; module splits each statement into tokens with PostgreSQL’s own scanner and replaces Greenplum’s syntax with the standard one. Its &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ProcessUtility_hook&lt;/code&gt; then picks up the options that PostgreSQL would otherwise reject with an error:&lt;/p&gt;

&lt;div class=&quot;language-sql highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sales&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bigint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dt&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;DISTRIBUTED&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;PARTITION&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;RANGE&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;START&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;date&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;2026-01-01&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;END&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;date&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;2027-01-01&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
   &lt;span class=&quot;k&quot;&gt;EVERY&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;interval&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;1 month&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;-- reaches PostgreSQL 19&apos;s grammar as (simplified):&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;CREATE&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;TABLE&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sales&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;id&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bigint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;dt&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;date&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;PARTITION&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;RANGE&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;WITH&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;distributed_by&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;(id)&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;n&quot;&gt;gp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;partition_by&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gp&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;PARTITION&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;BY&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;RANGE&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;dt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;START&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;...)&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gp&lt;/span&gt;&lt;span class=&quot;err&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;One statement stays one statement after the rewrite. An early version of the port that turned it into two broke every driver that uses prepared statements. Error positions still point into the original, unrewritten text. The grammar is one of the places a fork has to merge, with conflicts, at every new release.&lt;/p&gt;

&lt;p&gt;ORCA is written so that its four core libraries don’t include a single PostgreSQL header, which is why its 920 files compile from Cloudberry’s source tree without any changes. It talks to the database through the port’s Query ↔ DXL ↔ plan translator (27 files, about 33k lines) and a wrapper layer. Of the wrapper layer’s 198 functions, 170 needed nothing but &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;#include&lt;/code&gt; changes, and real API drift from the older PostgreSQL showed up in only six places. Whatever ORCA can’t digest has to be rewritten in the query before it gets there: every PostGIS spatial predicate, for example. If ORCA still declines a query, the reason is counted and the query takes the “gather route”: PostgreSQL’s own planner, with each distributed table gathered to the coordinator.&lt;/p&gt;

&lt;p&gt;Cloudberry’s private protocol messages would break a PostgreSQL 19 session, so the dispatcher is an ordinary libpq client, with SCRAM or TLS, that sends each slice inside &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SELECT gp_internal.exec_fragment(...)&lt;/code&gt;, and the segment’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;planner_hook&lt;/code&gt; puts that plan fragment in place of the query.&lt;/p&gt;

&lt;p&gt;The distributed snapshot arrives as a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SET LOCAL&lt;/code&gt;. On each segment a writer process runs one slice and reader processes run the rest: they adopt the writer’s transaction through R4’s new core function &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;XactAdoptTransactionState()&lt;/code&gt;, join its lock group, and resolve combo command IDs (combo CIDs) through the R2 hooks. Everything else is PostgreSQL’s standard parallel-worker machinery.&lt;/p&gt;

&lt;p&gt;Motions stream rows over TCP, over Cloudberry’s UDP with flow control, over UDP2, or through a proxy that multiplexes all the Motions between two nodes.&lt;/p&gt;

&lt;p&gt;Here is how a commit runs across the cluster:&lt;/p&gt;

&lt;figure class=&quot;diagram wide&quot;&gt;
  &lt;a href=&quot;https://igor-suhorukov.github.io/blog/assets/posts/greenplum-without-the-fork/commit.png&quot;&gt;&lt;img src=&quot;https://igor-suhorukov.github.io/blog/assets/posts/greenplum-without-the-fork/commit.png&quot; width=&quot;1141&quot; height=&quot;883&quot; alt=&quot;Sequence diagram of a commit. On COMMIT, gp_core on the coordinator sends PREPARE TRANSACTION &amp;#x27;gp_dtx_&amp;lt;xid&amp;gt;&amp;#x27;, where xid is the coordinator&amp;#x27;s FullTransactionId, to the segments that wrote, and they answer prepared. The coordinator then commits locally: RecordTransactionCommit() writes the commit record and decides the outcome, xact_commit_recorded_hook (O33) sends COMMIT PREPARED to the segments, they answer committed, then ProcArrayEndTransaction() makes the transaction visible to other sessions and the client gets COMMIT. If one segment wrote and the coordinator did not, the commit takes one phase; after a crash between the phases, a coordinator background worker finishes the prepared parts from the coordinator&amp;#x27;s clog.&quot; /&gt;&lt;/a&gt;
&lt;/figure&gt;

&lt;p&gt;Transactions work without a snapshot patch. Distributed snapshots looked impossible without core patches until the angle changed: make each segment’s snapshot agree with the coordinator’s, not the reverse. A segment waits for a transaction that the distributed snapshot says has committed but that the segment holds only as prepared, and it hides one that it has committed locally but that is still running globally. A replication slot keeps VACUUM away from the rows such transactions deleted. The coordinator’s own commit record decides every outcome, and a transaction that only one segment wrote commits in one phase: single-row INSERTs went from 371 to 950 transactions per second. Cloudberry’s global deadlock detector runs as it is, in a background worker.&lt;/p&gt;

&lt;p&gt;As for storage and failover: append-optimized tables are table access methods whose blocks are ordinary 8K pages, logged by a custom WAL resource manager, so standbys, base backups and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_checksums&lt;/code&gt; see normal pages. PAX’s C++ code compiles in place, with 65 of its 91 files unchanged. External tables are an FDW. Directory tables are now WAL-logged, which Cloudberry’s are not. FTS is a background worker that probes segments over SQL and promotes mirrors with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_promote()&lt;/code&gt;, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpfts&lt;/code&gt; instances elect a leader through etcd to fail over the coordinator.&lt;/p&gt;

&lt;h2 id=&quot;the-components&quot;&gt;The components&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Module&lt;/th&gt;
      &lt;th&gt;Role&lt;/th&gt;
      &lt;th&gt;Plugs in via&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_core&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;cluster file, dispatcher, Motions, interconnect, DDL on every node, 2PC, distributed snapshots, deadlock detector, FTS, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_toolkit&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;executor, utility and planner hooks; background workers; custom WAL rmgr; security labels; CustomScan; R1, R2, R4, O3, O10, O29–O33&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_orca&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;ORCA, translator, fallback counters, pre-ORCA rewrites&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;planner_hook&lt;/code&gt;, CustomScan, EXPLAIN hooks&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_sql&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Cloudberry syntax, tags, directory tables, extension scripts on every node&lt;/td&gt;
      &lt;td&gt;O26, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ProcessUtility_hook&lt;/code&gt;, a WAL rmgr&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_ao&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pax&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;append-optimized row and column tables, PAX, bitmap index&lt;/td&gt;
      &lt;td&gt;table AM, O13–O18, O21, O23, WAL rmgrs&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_exttable&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pxf_fdw&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpcloud&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;datalake_fdw&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;external data: file, gpfdist, http, s3, PXF, Iceberg&lt;/td&gt;
      &lt;td&gt;FDW, table AM&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_resource&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_security&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_matview&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_task&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;diskquota&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;resource groups on cgroups, memory protection, profiles, incremental matviews, task scheduler, quotas&lt;/td&gt;
      &lt;td&gt;labels, O25, O27, O28, O21, background workers&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpfts&lt;/code&gt;, gpMgmt&lt;/td&gt;
      &lt;td&gt;coordinator failover; Cloudberry’s management tools&lt;/td&gt;
      &lt;td&gt;programs working over SQL and PostgreSQL 19’s own &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;initdb&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_ctl&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_basebackup&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg_rewind&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;What the administrator sees:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_core&lt;/code&gt; loads first, and the modules that can only be preloaded refuse &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LOAD&lt;/code&gt;, so a server never runs half-initialized.&lt;/li&gt;
  &lt;li&gt;The port has no shared catalogs of its own. Role attributes live in shared security labels, secrets in a maintenance database, and the topology in a cluster file. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp_segment_configuration&lt;/code&gt; and its relatives are views.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;limitations&quot;&gt;Limitations&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Inherited from PostgreSQL 19:
    &lt;ul&gt;
      &lt;li&gt;extension settings must have a dot in their names, hence &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gp.*&lt;/code&gt;;&lt;/li&gt;
      &lt;li&gt;command tags are a fixed list;&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CHECK_FOR_INTERRUPTS()&lt;/code&gt; takes no callbacks, so a running query moves to another resource group only while it is waiting for something;&lt;/li&gt;
      &lt;li&gt;a cursor fetched in batches never runs in parallel.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;The port compared with Cloudberry:
    &lt;ul&gt;
      &lt;li&gt;Cloudberry’s MPP variant of the PostgreSQL planner, “Route B”, isn’t ported, so whatever ORCA declines takes the slower gather route;&lt;/li&gt;
      &lt;li&gt;planning with ORCA costs more on short queries: 13.7 ms against 0.25 ms for a one-row point lookup, &lt;a href=&quot;https://github.com/quantumiodb/pgorca/blob/63e4e96c2454cdfd154b0b78d5dfcf19de70a775/TODO.md?plain=1#L68-L75&quot;&gt;as measured by the pgorca project&lt;/a&gt;;&lt;/li&gt;
      &lt;li&gt;parallelism inside a segment covers only the writer’s slice;&lt;/li&gt;
      &lt;li&gt;SERIALIZABLE on a cluster is REPEATABLE READ, as in Greenplum.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;The nature of MPP:
    &lt;ul&gt;
      &lt;li&gt;the coordinator is the only planner and the single entry point;&lt;/li&gt;
      &lt;li&gt;joins that aren’t on the distribution key move data over the network;&lt;/li&gt;
      &lt;li&gt;unique constraints must include the distribution key;&lt;/li&gt;
      &lt;li&gt;adding nodes means redistributing data.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;how-it-was-built&quot;&gt;How it was built&lt;/h2&gt;

&lt;p&gt;On September 17, six research agents each studied their own part of Cloudberry’s code. A script checked all 1,440 of their &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;file:lines&lt;/code&gt; references, and the same day 14 implementation decisions were made in a conversation with the LLM. The plan grew into a journal of more than 16,000 lines, where &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Built &amp;lt;date&amp;gt;&lt;/code&gt; is reserved for code that was written, run and tested, and a corrections section records where reality disagreed with it. One entry reads “a test that passes may check nothing”: an interconnect test had been passing since M2 while a bare &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;except&lt;/code&gt; swallowed its failures.&lt;/p&gt;

&lt;p&gt;The agents settled implementation questions on their own and wrote down why they chose what they did. Anything that touched the PostgreSQL core, a shared branch or a deletion came to me. Large milestones were split between parallel agents, each with its own branch and worktree: nine agents built M8, and seven more cleaned up bugs and skipped tests.&lt;/p&gt;

&lt;figure class=&quot;diagram&quot;&gt;
  &lt;a href=&quot;https://igor-suhorukov.github.io/blog/assets/posts/greenplum-without-the-fork/timeline.png&quot;&gt;&lt;img src=&quot;https://igor-suhorukov.github.io/blog/assets/posts/greenplum-without-the-fork/timeline.png&quot; width=&quot;784&quot; height=&quot;388&quot; alt=&quot;Gantt chart of the port by git log, September 2026: research with six agents, 14 decisions, the plan and the first six core patches on September 17; milestones M0 to M8 from September 18 to 28; the core series shrunk from 28 to 22 patches, pgvector and a bug sweep on September 28.&quot; /&gt;&lt;/a&gt;
&lt;/figure&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Milestone&lt;/th&gt;
      &lt;th&gt;Dates&lt;/th&gt;
      &lt;th&gt;Result&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;M0 skeleton&lt;/td&gt;
      &lt;td&gt;Sept 18&lt;/td&gt;
      &lt;td&gt;all modules build and load; vanilla checks pass&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;M1 one node&lt;/td&gt;
      &lt;td&gt;Sept 18–22&lt;/td&gt;
      &lt;td&gt;Cloudberry SQL, ORCA, PostGIS; PostgreSQL’s 239 regression tests line for line&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;M2 cluster&lt;/td&gt;
      &lt;td&gt;Sept 22–24&lt;/td&gt;
      &lt;td&gt;dispatcher, Motions, streaming interconnect&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;M3 transactions&lt;/td&gt;
      &lt;td&gt;Sept 23–24&lt;/td&gt;
      &lt;td&gt;2PC, distributed snapshots, deadlock detector&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;M4–M6&lt;/td&gt;
      &lt;td&gt;Sept 24–26&lt;/td&gt;
      &lt;td&gt;mirrors and FTS; AO, PAX, external tables; resource groups&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;M7 parity and tools&lt;/td&gt;
      &lt;td&gt;Sept 26–27&lt;/td&gt;
      &lt;td&gt;gpMgmt on PostgreSQL 19’s tools, PostGIS on a cluster, TPC measured&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;M8&lt;/td&gt;
      &lt;td&gt;Sept 27–28&lt;/td&gt;
      &lt;td&gt;UDP2, proxy, gpfts, gpexpand, four add-on extensions&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Commits per day: 10, 9, 15, 26, 31, 29, 87, 51, 87, 186 and 98. The first days went into ORCA and its translator, where understanding what was going on mattered more than speed; that was where the foundation was laid. From September 24, once the cluster worked and tests ran in parallel, the milestones started to overlap.&lt;/p&gt;

&lt;p&gt;There were a few surprises along the way:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Zero segments. At first, single-node mode reported zero segments. A debug build of ORCA quietly handed every query to the fallback planner, while a release build divided by zero and clamped infinity to 1e+250, producing plausible plans by pure chance.&lt;/li&gt;
  &lt;li&gt;Bit 0x0400. Cloudberry decides whether to call ORCA by cursor flag 0x0400, which in PostgreSQL 19 means &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CURSOR_OPT_CUSTOM_PLAN&lt;/code&gt;. A literal port would have compiled without a single error and turned ORCA off for every custom plan.&lt;/li&gt;
  &lt;li&gt;Lock upgrades. When Cloudberry’s table lock was taken after the parser’s weaker lock, 87% of concurrent UPDATEs in pgbench failed. That’s where core patch O30 came from.&lt;/li&gt;
  &lt;li&gt;My Linux desktop kept dying. Forty-three parallel test jobs used up 60 GB of RAM, and the OOM killer took my desktop session down along with them. Now the runner starts a job only if the memory declared for it fits into what is free.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;non-functional-requirements-and-orca-on-tpc&quot;&gt;Non-functional requirements and ORCA on TPC&lt;/h2&gt;

&lt;p&gt;The requirements I measured were vanilla behaviour, hook overhead, ABI stability, reproducible builds and correct answers. Every figure comes from Docker images built from committed branches. A full run of the port’s test suites is 58–64 jobs and takes 500–900 seconds.&lt;/p&gt;

&lt;p&gt;The deciding measurement concerned Cloudberry’s MPP planner (Route B). My instruction was to fix the ORCA fallbacks caused by the port itself first, and then, before deciding anything, to measure a real workload.&lt;/p&gt;

&lt;p&gt;The setup:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;TPC-H’s 22 and TPC-DS’s 99 queries as DuckDB ships them, on scale factor 1 data;&lt;/li&gt;
  &lt;li&gt;one coordinator and four segments in one container, on a 16-core host with 60 GB of memory;&lt;/li&gt;
  &lt;li&gt;no assertions, data in tmpfs, JIT and parallel query off;&lt;/li&gt;
  &lt;li&gt;DuckDB’s answers as the reference, compared to the cent.&lt;/li&gt;
&lt;/ul&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;TPC-H&lt;/th&gt;
      &lt;th&gt;TPC-DS&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Planned by ORCA&lt;/td&gt;
      &lt;td&gt;22/22&lt;/td&gt;
      &lt;td&gt;99/99&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Answers equal to DuckDB’s&lt;/td&gt;
      &lt;td&gt;all&lt;/td&gt;
      &lt;td&gt;all&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ORCA vs the gather route, geometric mean&lt;/td&gt;
      &lt;td&gt;3.1× (4.4 s vs 123 s, 20 queries)&lt;/td&gt;
      &lt;td&gt;4.0× (25 s vs 386 s, 96 queries)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Not finished in 120 s on the gather route&lt;/td&gt;
      &lt;td&gt;q17, q20&lt;/td&gt;
      &lt;td&gt;04, 14, 64&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Source data:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The benchmark harness is &lt;a href=&quot;https://github.com/igor-suhorukov/cloudberry/tree/16b1ef30668f13423f44762eaeca3a252dc52319/pg19/test/tpc&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pg19/test/tpc&lt;/code&gt;&lt;/a&gt; in the port: it generates the data, loads the cluster, runs every query under ORCA and on the gather route, and compares the rows.&lt;/li&gt;
  &lt;li&gt;The queries and reference answers come from DuckDB 1.5.5’s own kits: TPC-H &lt;a href=&quot;https://github.com/duckdb/duckdb/tree/v1.5.5/extension/tpch/dbgen/queries&quot;&gt;queries&lt;/a&gt; and &lt;a href=&quot;https://github.com/duckdb/duckdb/tree/v1.5.5/extension/tpch/dbgen/answers/sf1&quot;&gt;SF1 answers&lt;/a&gt;, TPC-DS &lt;a href=&quot;https://github.com/duckdb/duckdb/tree/v1.5.5/extension/tpcds/dsdgen/queries&quot;&gt;queries&lt;/a&gt; and &lt;a href=&quot;https://github.com/duckdb/duckdb/tree/v1.5.5/extension/tpcds/dsdgen/answers/sf1&quot;&gt;SF1 answers&lt;/a&gt;.&lt;/li&gt;
  &lt;li&gt;A recorded run of the harness is in the message of commit &lt;a href=&quot;https://github.com/igor-suhorukov/cloudberry/commit/beaf7379d12abeab98eebe1750e441182f26dda2&quot;&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;beaf7379d12&lt;/code&gt;&lt;/a&gt;: 121 of 121, 3.13× and 3.94×, and the same five queries unfinished.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The totals in the table come from the first measurement, which ran three rounds.&lt;/p&gt;

&lt;p&gt;Under ORCA those five queries take 0.2–1.6 s, and all 121 run in 34 s. On six short queries (0.1–0.75 s) ORCA is more than a fifth slower. On Cloudberry’s regression suite, the port’s own reasons for falling back from ORCA went down from 563 to 52; what remains is the list ORCA inherited, non-default collations among them. The agent suggests concentrating on cutting ORCA’s fallbacks to the PostgreSQL planner rather than porting Route B’s 30–45k lines. I haven’t made my own decision about porting Cloudberry’s MPP planner yet, but the plan for implementing it is already written.&lt;/p&gt;

&lt;h2 id=&quot;the-outcome&quot;&gt;The outcome&lt;/h2&gt;

&lt;p&gt;Twelve days ago it wasn’t clear whether Greenplum could be built as an extension of a modern PostgreSQL without turning into a completely different product. Now there is code, and almost all of the functionality has been ported:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;a PostgreSQL core with 22 small patches of mine, provably vanilla in behaviour and compatibility when the Greenplum extension isn’t loaded;&lt;/li&gt;
  &lt;li&gt;Cloudberry’s and ORCA’s sources, unmodified;&lt;/li&gt;
  &lt;li&gt;stock PostGIS and pgvector work, and other extensions should too; for pgvector’s nearest-neighbour search there is even a planner optimization that pushes &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ORDER BY ... LIMIT ...&lt;/code&gt; down to the segments;&lt;/li&gt;
  &lt;li&gt;more than 90% of the database code ported as extensions: 93%, or 97% if you leave out Cloudberry’s MPP planner (Route B in my plan). ORCA’s 380k lines are reused as they are, through a wrapper in the extension’s code, and are counted on neither side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So far only 691 of Cloudberry’s 1,180 test files run, and that coverage has to grow. Next on the list are the rest of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;isolation2&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;greenplum_schedule&lt;/code&gt;. I may also port Cloudberry’s MPP planner, as a fallback for ORCA.&lt;/p&gt;

&lt;p&gt;The most important lesson of these twelve days: porting Greenplum as a PostgreSQL extension worked not because an LLM simply writes code fast, but because the development process was set up the way a good engineering team works.&lt;/p&gt;

&lt;p&gt;Research into the code came with references to the exact lines it relied on, with prototypes, and with measurements of them. Invariants were checked by tests during coding and refactoring, and “done” meant that the new functionality passes the original system’s tests. Where a result differed, the difference was recorded the same way: tied to facts about where development stood in that phase, and to the later phase in which the unmodified test was expected to pass. To speed up iterations, the tests ran in parallel in Docker containers, making the most of the machine’s 32 threads and 64 GB of RAM.&lt;/p&gt;

&lt;p&gt;In the last stages the agents worked in parallel, in isolated worktrees, while every serious call was mine: which path development should take, or what to do when the architecture had to deviate from the plan. The Greenplum fork took years to move from PostgreSQL 9.4 to 12; for the extension, a new major version means editing 22 small core patches and the extension code that goes with them.&lt;/p&gt;

&lt;p&gt;Still running an old PostgreSQL? Then we’re coming to you!&lt;/p&gt;

&lt;p&gt;The project: &lt;a href=&quot;https://github.com/igor-suhorukov/postgres/tree/REL_19_STABLE_CLOUDBERRY&quot;&gt;core patch series&lt;/a&gt;, &lt;a href=&quot;https://github.com/igor-suhorukov/cloudberry/tree/extension_postgresql_19/pg19&quot;&gt;port&lt;/a&gt;, &lt;a href=&quot;https://github.com/igor-suhorukov/cloudberry/tree/extension_postgresql_19/pg19#building&quot;&gt;build instructions&lt;/a&gt;.&lt;/p&gt;</content>
  </entry>
</feed>
