Learning Platform
Урок 15.02 · 25 мин
Продвинутый
Vectorized executionBatched executionSIMDCPU cacheDuckDBClickHouse

В прошлом уроке мы посмотрели, как тратится 95% CPU на простом OLAP-запросе в Postgres: function call overhead, tuple deform, MVCC checks, и только 2-5% — на полезную работу. В этом уроке смотрим, как vectorized execution перестраивает этот пирог.

Тема для senior-backend и data-инженеров критическая: к 2026 году аналитическая нагрузка почти везде утекла с Postgres на специализированные движки (DuckDB, ClickHouse, Snowflake, BigQuery). Понять «почему» — значит понимать ровно один архитектурный приём.

Одна правка в next()

Vectorized model (она же batched execution) родилась в проекте MonetDB/X100 (CWI, ~2005) и популяризирована в статье “MonetDB/X100: Hyper-Pipelining Query Execution”. Изменение в volcano минимальное:

  • Было: next() возвращает 1 строку.
  • Стало: next() возвращает batch (vector) из 1024-10240 значений одной колонки.

То есть вместо Tuple узел отдаёт Vector<int32> или Vector<varchar> — массив значений фиксированной длины. Колонки a, b, c идут отдельными векторами, организованными в DataChunk (DuckDB) или Block (ClickHouse).

Volcano vs Vectorized: что течёт между узлами

Слева: volcano отдаёт по одной row. Справа: vectorized отдаёт vector из 1024 значений одной колонки. Узел делает работу за весь vector сразу.

Volcano (Postgres)next() → row
row 1[a=10, b=20, c=30]
row 2[a=11, b=21, c=31]
row 3[a=12, b=22, c=32]
...100M вызовов next()
Vectorized (DuckDB / ClickHouse)next() → vector(1024)
vector a[10, 11, 12, ..., 1033]
vector b[20, 21, 22, ..., 1043]
vector c[30, 31, 32, ..., 1053]
всего100M / 1024 ≈ 100K вызовов next()

Всё, что было модульно в volcano, остаётся модульным: HashJoin всё ещё не знает, что под ним. Только теперь он принимает не Tuple, а DataChunk — структуру с N векторами. И вся работа узла выполняется батчем.

Почему это меняет всё

Кажется, мелкая правка: «отдаём не одну, а 1024». Но из неё выливается серия архитектурных эффектов.

1. Function call amortized

Вместо 100M вызовов next() — 100K. Function call overhead падает в 1000 раз. И поскольку в каждом batch одинаковый тип данных, indirect call всегда попадает в branch predictor — никаких misprediction.

2. SIMD естественно работает

CPU имеет

SIMD-регистры
на 256 бит (AVX2) или 512 бит (AVX-512). Это значит: за один такт можно сложить 8 int32 или 16 int16. Но эти регистры можно загрузить только если значения лежат рядом в памяти — например, [10, 11, 12, 13, 14, 15, 16, 17]. Это ровно то, что даёт vector.

Compiler сам генерирует SIMD-код для простого цикла:

// псевдокод vectorized filter
for (int i = 0; i < 1024; i++) {
    selection[i] = data[i] > 50000;
}

GCC/Clang компилирует это в AVX2-инструкции, и фильтр выполняется со скоростью 8 значений за такт. Сравни с row-by-row: каждое значение — отдельный compare + branch.

3. CPU cache hit rate

Vector из 1024 int32 = 4 KiB. Два-три вектора умещаются в L1 (32-64 KiB). Узел работает с горячим L1-кэшом на протяжении всего batch’а, потом переключается на следующий узел — но и тот тоже видит batch в L2 (свежий из L1).

В volcano модели в любой момент времени в L1 лежит один tuple slot, один buffer page (8 KiB), несколько структур узлов. Любой межузловой переход — потенциально L1 miss.

4. Branch misprediction исчезает

В volcano filter выглядит так: if (tuple.c > 50000) emit(tuple). На случайных данных predictor ошибается 50% времени. В vectorized — мы не «эмиттим» сразу, а накапливаем selection vector (bitmask или массив индексов), и потом передаём дальше уже только прошедшие. Branch заменяется на data-dependent move (CMOV), у которого нет predictor.

5. Tuple deform уходит

В volcano распаковка row из heap tuple — 20-30% времени. В vectorized данные уже хранятся как колонки (см. следующий урок про column store) или, как минимум, decode’ятся в колонки на первом узле — и дальше живут в этом виде. Никаких alignment-paddings, null-bitmap’ов на каждой строке.

6. Selection vector вместо if/break

В volcano filter работает так: для каждой строки if (predicate(row)) emit(row); else continue;. Это branchy код. В vectorized filter не emit’ит сразу — он строит selection vector: либо bitmask (1 бит на позицию vector’а), либо плотный массив индексов прошедших.

// псевдокод vectorized filter с selection vector
int sel_count = 0;
for (int i = 0; i < 1024; i++) {
    sel[sel_count] = i;
    sel_count += (data[i] > 50000) ? 1 : 0;  // branchless через CMOV
}

Этот код компилируется в branchless машинный — никаких jump-инструкций по condition. CPU предсказатель ветвлений не трогается. Дальше вектор sel передаётся следующему узлу: «работай только с позициями [sel[0], sel[1], …, sel[sel_count-1]] исходных векторов». Это selection vector pattern, изобретённый ещё в MonetDB.

Сравним стоимости

Где уходит CPU: volcano vs vectorized на том же запросе

SELECT sum(amount) FROM orders на 100M строк. Условная разбивка по типам циклов CPU. Реальная разница на бенчмарках TPC-H — около 30-50x в пользу vectorized.

Volcano (Postgres)suma: 12 секунд
ExecProcNode + calls~40%
tuple deform~25%
MVCC check~15%
полезная арифметика~3%
прочее~17%
Vectorized (DuckDB)суммa: 0.3 секунды
function call (amortized)~3%
vector loop~10%
SIMD add (полезная работа)~50%
memory load~25%
прочее~12%
выводvectorized превращает 'overhead-bound' нагрузку в 'compute/memory-bound' — то, что должно было быть с самого начала

Не «volcano + SIMD = vectorized». А именно архитектурная перестройка операции на batch level. Все остальные оптимизации (SIMD, cache locality, branch elimination) — следствия.

Вариации vectorized: pure vs hybrid

Vectorized execution не монолит — есть нюансы реализации:

  • Pure vectorized (DuckDB, MonetDB): всё внутреннее состояние engine — векторы. Каждый узел получает DataChunk и возвращает DataChunk.
  • Vectorized + code generation (Photon Databricks, Snowflake, HyPer): на стадии планирования генерится специализированный C++/LLVM-код, который уже знает типы колонок и обходит interpret-loop. Это гибрид: batched + JIT, теоретически даёт ещё +2-3x поверх pure vectorized.
  • Morsel-driven parallelism (HyPer): vector чанки (морселы) распределяются по workers через work-stealing. Это позволяет почти линейный speedup на многоядерных CPU без блокировок shared-state.

ClickHouse начинал с pure vectorized с огромным vector size (65536), сейчас активно добавляет code generation для горячих путей. DuckDB ставит на pure vectorized с фокусом на простоту code-path’а и предсказуемость performance.

Размер vector’а: почему 1024-10240

Выбор размера батча — компромисс:

  • Слишком маленький (< 256) — overhead next() начинает доминировать. Function call amortization падает.
  • Слишком большой (> 65536) — vector не помещается в L1/L2 cache, преимущество cache locality исчезает.
  • Sweet spot: 1024-2048 значений × 4-8 байт = 4-16 KiB на vector. Помещается в L1 (32-64 KiB) вместе с парой соседних структур.

DuckDB: 2048. ClickHouse: 65536 (большой, потому что заточен под огромные плотные сканы distributed). Apache Arrow recordbatch: рекомендуется 1024-65536, конкретный размер выбирает приложение.

Где встречаешь vectorized в реальности

  • DuckDB — embedded аналитический движок. Самый чистый vectorized: chunk size = 2048, в памяти, intra-process. Главный конкурент SQLite для OLAP.
  • ClickHouse — open-source columnar OLAP. Vectorized + columnar storage + distributed. Vector size = 65536 (огромный, чтобы амортизировать межсерверный transfer).
  • Snowflake / Photon (Databricks) — облачные columnar warehouses. Vectorized движки на C++ под Spark/SQL.
  • DataFusion (Apache Arrow) — вектор-фреймворк на Rust. Используется в Ballista, InfluxDB IOx, многих коммерческих движках.

Postgres сам по себе vectorized не делает. Есть расширения (см. следующий урок) — Citus columnar и pg_lakehouse — которые добавляют vectorized путь поверх. Но в core PG до сих пор честный volcano. На roadmap PG 18-19 обсуждаются эксперименты с “batched execution”, но это пока research.

Память: где живёт vector

Vector в vectorized engine — это плотный массив в RAM. Базовый аллокатор обычно — арена / bump-allocator на запрос: при начале запроса резервируется большой блок RAM, и все векторы выделяются из него последовательно. По окончании запроса арена сбрасывается одним движением.

Это даёт двойную выгоду: (1) нулевая фрагментация, (2) аллокация = бамп указателя (10-20 циклов CPU вместо malloc’a в сотни циклов). Postgres использует похожий приём через memory contexts (palloc в ExecutorState context), но granularity там — отдельные структуры, не векторы.

Важный нюанс: для строковых колонок vector — это массив указателей на heap-allocated значения, плюс отдельная арена, где живут сами байты строк. То есть varchar[1024] — это 1024 указателя по 8 байт (8 KiB указателей) + сами данные строк в соседней арене. Это позволяет SIMD-операции над указателями (например, фильтр по IS NULL), но строковые сравнения остаются скалярными.

Как это видно на сравнении

В sandbox мы можем запустить Postgres-запрос и потом представить, как тот же запрос вёл бы себя на vectorized engine:

Простой OLAP в Postgres. На 100K строк это работает быстро, но представь масштаб 100M: умножь время на 1000. Это и есть аналитическая боль OLTP-движков.

PostgreSQL

Тот же запрос на DuckDB на 100M строк проходит за ~0.5 секунды (на ноутбуке). Postgres на тех же данных, с теми же индексами, без расширений — 2-3 минуты. Не из-за «плохого Postgres», а из-за разницы архитектур.

Попробуем второй запрос — с filter + aggregation, где volcano особенно слаб:

Filter + aggregate. Volcano прокачивает каждую строку через узлы. Vectorized сделал бы то же самое одним SIMD-сканом vector'а — 8 значений за такт CPU.

PostgreSQL

В Postgres это Seq Scan + Filter + HashAggregate. На vectorized engine — batched scan по колонке placed_at (SIMD compare), затем selection vector кладёт прошедшие индексы, и aggregation по trois колонкам идёт SIMD-add’ами. Та же логика, в 30-50 раз быстрее.

Null handling: отдельная боль

Vectorized движкам нужно эффективно обрабатывать NULL. Реализуется обычно через validity bitmap (Apache Arrow): отдельный 1-bit-per-value битмап рядом с массивом данных. Bit = 1 значит valid, 0 значит null.

Это важно для SIMD: data-операции работают на raw байтах независимо от null-bitmap, а потом null-bitmap применяется как маска. На сравнениях типа WHERE x > 5 результат — это AND двух bitmap’ов: «прошёл сравнение» AND «не null». SIMD умеет AND’ить bitmap’ы со скоростью 256 бит за такт.

В row store null хранится в null bitmap каждого tuple отдельно, и проверка делается на каждой строке через att_isnull(slot, attno). Это ещё один источник tuple deform overhead’а.

Постгрес и vectorized: фантомные планы

В PG-сообществе периодически появляются proof-of-concept’ы vectorized executor’а для core PG. Самая заметная попытка — VOPS (Vectorized OPerations), forks на GitHub. Идея: добавить специальные типы данных vop_int4 и т.д., которые внутри — векторы из ~1024 значений. SQL переписывается, чтобы оперировать этими типами, и planner выбирает specialized execution path.

Результаты: 10-20x speedup на TPC-H Q6, но требует переписать запросы и таблицы. То есть полноценной интеграции не получилось — нативный vectorized в Postgres core означал бы перепроектировать executor, MVCC и tuple slot.

Поэтому реальный путь Postgres-экосистемы — это расширения через TAM API (Citus columnar, Hydra), и отдельный движок через CDC (ClickHouse, DuckDB), а не переписывание core.

Когда vectorized не выигрывает

Архитектура не панацея.

  • Высокая селективность фильтра. Если запрос трогает 1 строку из миллиарда, batched loop всё равно прокачивает batch’и впустую (хотя селективные оптимизации помогают). Volcano с index lookup всё ещё король.
  • OLTP-нагрузка. Транзакция, которая делает один UPDATE одной строки, — это не то, ради чего batched execution существует. Vectorized engines обычно вообще не имеют MVCC или имеют примитивный (Snapshot Isolation на immutable storage).
  • Очень узкие плоские таблицы. Если у тебя таблица с 2 колонками и запрос трогает обе, выигрыш меньше — меньше «лишней» работы было в volcano.
  • Запросы с десятками тысяч мелких сабквери. Каждый сабквери = свой batched loop с собственным overhead’ом инициализации.

Поэтому DuckDB не пытается заменить Postgres. И поэтому ClickHouse прямо в документации пишет «не используйте нас для transaction processing». Это инструмент конкретной формы.

Проверка знанийKnowledge check
Команда переезжает с Postgres на vectorized engine для аналитики (DuckDB embedded внутри сервиса). На некоторых запросах выигрыш 50x, на других — всего 2x. На третьих DuckDB **проигрывает** Postgres в 1.5 раза. Какие признаки запросов из каждой категории ты бы ожидал?
ОтветAnswer
50x выигрыш: full scan широкой таблицы с aggregation, минимальная селективность, простая арифметика. Точно типичный OLAP — sum/avg/group by по большой таблице. SIMD + vectorized loop полностью раскрывают потенциал. 2x выигрыш: запросы с index-friendly условиями (small range на индексированной колонке) или с многими сложными выражениями типа regex, JSON parsing. SIMD не помогает там, где работа сложна; volcano уже использует индекс эффективно. DuckDB проигрывает (1.5x хуже): запросы, где Postgres использует индекс для быстрого lookup малого числа строк (например, primary key fetch). DuckDB на embedded режиме читает batched, чтение одной строки = чтение всего блока (тысяч строк), это лишняя работа. Vectorized — про объём, не про точечные запросы. Также может проигрывать на запросах с heavy joins, где у DuckDB нет нужного индекса, а у Postgres есть.

Где это потрогать руками

Хочешь увидеть разницу не на словах — DuckDB ставится как single-binary за минуту:

  • brew install duckdb или скачать с duckdb.org. Никаких серверов.
  • Стандартный SQL, очень близкий к Postgres SQL.
  • Можно подключиться к Postgres напрямую: INSTALL postgres; LOAD postgres; ATTACH 'host=... dbname=...' AS pg (TYPE postgres); и потом SELECT * FROM pg.orders — DuckDB прочитает данные Postgres и обработает их своим vectorized engine.
  • Или экспорт в Parquet: COPY (SELECT * FROM pg.orders) TO 'orders.parquet' (FORMAT PARQUET); — и дальше работа с локальным Parquet, который весит в 5-10 раз меньше heap’а.

Это самый дешёвый способ почувствовать разницу: тот же SQL, те же данные, разные движки. Time EXPLAIN ANALYZE рядом — наглядное доказательство теории.

Чек-лист

  • Vectorized execution = volcano, где next() возвращает batch (vector) из 1024-10240 значений.
  • Эффекты: function call amortized, SIMD возможен, L1 cache hit, branch misprediction исчезает, tuple deform не нужен.
  • Real-world skoki: 30-50x на TPC-H класс запросов против row-by-row.
  • Используется в DuckDB, ClickHouse, Snowflake, Photon, DataFusion.
  • Postgres core — volcano. Vectorized-расширения существуют (следующий урок), но это не «нативный» путь PG.
  • Не панацея: проигрывает на OLTP, на index lookup, на mostly-skip нагрузке.
  • Архитектурная перестройка важнее, чем «добавим SIMD» — последнее без батчей бесполезно.
Branch prediction: pipeline и почему sorted данные быстрее Row vs Columnar

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. В чём ключевая архитектурная разница vectorized execution относительно volcano model?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5