Learning Platform
Глоссарий Troubleshooting
Урок 06.01 · 23 мин
Средний
vectorized-engineexecution-modelperformance

Vector-at-a-time против построчной обработки

Любая СУБД исполняет запрос как конвейер операторов: сканирование таблицы, фильтр, агрегация. Вопрос, который определяет всю производительность движка, звучит так: какими порциями данные движутся между операторами этого конвейера? Ответов исторически три, и от выбора зависит, будет движок быстрым или медленным в разы.

DuckDB — векторизованный движок, и это его главное архитектурное решение. Чтобы понять, что такое «векторизация» и почему она быстрая, нужно увидеть две альтернативы, между которыми она стоит: построчную обработку (tuple-at-a-time) и полную материализацию (full materialization). Векторизация — это компромисс между ними, и компромисс на удивление удачный. Этот урок открывает модуль про движок DuckDB и закладывает фундамент: модель vector-at-a-time, на которой держится всё остальное.


Модель первая: построчная обработка (tuple-at-a-time)

Классический подход, придуманный в 1970-х и описанный в архитектуре Volcano, — обрабатывать по одной строке (tuple) за раз. Конвейер операторов работает так: верхний оператор просит у нижнего «дай следующую строку», тот просит у своего нижнего, и так до самого сканирования таблицы. Одна строка проходит весь конвейер сверху донизу, потом следующая.

Модель элегантна и проста. Каждый оператор — это итератор с методом «дай следующую строку». Но у неё есть скрытая, разрушительная цена.

Представьте простую операцию «прибавить 1 к колонке». В построчной модели для каждой строки происходит вызов функции. Миллион строк — миллион вызовов функции сложения. А каждый вызов функции — это не бесплатная операция: настройка стекового фрейма, передача аргументов, переход, возврат. Полезная работа (само сложение) — это один такт процессора. Накладные расходы вокруг неё — десятки тактов. На миллионе строк полезная работа тонет в океане накладных расходов на вызовы.

Построчная обработка: накладные расходы на каждую строку
строка 1Одна строка проходит весь конвейер операторов сверху донизу. На каждый оператор — отдельный вызов функции.
строка 2
строка 2Следующая строка — снова полный набор вызовов функций через все операторы конвейера.
...
строка NИ так миллион раз. Накладные расходы на вызовы умножаются на число строк и съедают всё время.

К стоимости вызовов добавляется ещё один штраф — интерпретация. Движок не знает заранее, какие операции запрос потребует, — он узнаёт это из плана во время выполнения. Для каждой строки он вынужден заново «соображать»: какой оператор, какой тип данных, какую функцию вызвать. Это интерпретация выражения, и она повторяется на каждой строке. Построчная модель платит за интерпретацию миллион раз там, где можно было бы заплатить один раз на миллион строк.

Построчная модель хороша для OLTP-нагрузки: транзакция трогает одну-две строки, накладные расходы на одну строку несущественны. Но аналитический запрос сканирует миллионы строк — и тут построчная модель катастрофически медленна. Именно поэтому OLTP-СУБД вроде SQLite или PostgreSQL, исторически построчные, проигрывают на аналитике.


Модель вторая: полная материализация

Противоположная крайность — полная материализация (full materialization), типичная для ранних колоночных систем вроде классического MonetDB. Здесь каждый оператор обрабатывает всю колонку целиком и материализует (записывает в память) полный результат, прежде чем передать его следующему оператору.

Накладные расходы на вызовы функций исчезают: оператор «прибавить 1» получает всю колонку и проходит её плотным циклом — один вызов на всю колонку, никакой построчной интерпретации. Это очень быстро по части CPU.

Но появляется новая проблема — память. Каждый промежуточный результат материализуется целиком. Запрос с пятью операторами материализует пять полных колонок-промежутков. Если таблица — десятки гигабайт, промежуточные результаты тоже десятки гигабайт. Они не помещаются в кэш процессора, не помещаются, возможно, и в оперативную память. Движок упирается в пропускную способность памяти, а на больших данных — вынужден писать промежутки на диск.

Полная материализация: промежутки целиком в памяти
Оператор 1: вся колонка -> результат целикомОператор обрабатывает всю колонку плотным циклом — CPU быстро. Но весь результат записывается в память.
полная колонка-промежуток
Оператор 2: вся колонка -> результат целикомСледующий оператор снова материализует полный результат. Промежутки накапливаются в памяти.
промежутки переполняют кэш и RAM
упор в пропускную способность памятиБольшие промежуточные результаты не влезают в кэш процессора, иногда и в RAM. Движок ограничен скоростью памяти, не CPU.

Полная материализация решила проблему накладных расходов на вызовы, но создала проблему памяти. Это другая крайность того же спектра.


Модель третья: vector-at-a-time

DuckDB выбрал середину — обработку векторами (vector-at-a-time), идею, выросшую из исследований CWI вокруг MonetDB/X100 (он же Vectorwise). Данные движутся по конвейеру не по одной строке и не целыми колонками, а порциями фиксированного размера — векторами. Вектор в DuckDB — это около 2048 значений одной колонки (точное число и его обоснование — тема следующего урока).

Конвейер работает так: сканирование выдаёт вектор примерно из 2048 значений, оператор «фильтр» обрабатывает этот вектор целиком плотным циклом, передаёт результат оператору «агрегация», тот тоже обрабатывает вектор целиком. Когда вектор пройден всеми операторами — берётся следующий вектор.

Vector-at-a-time: порции по ~2048 значений
СканСканирование выдаёт не одну строку и не всю колонку, а вектор — порцию примерно из 2048 значений одной колонки.
вектор
ФильтрОператор обрабатывает весь вектор одним плотным циклом: один вызов функции на ~2048 значений, без построчной интерпретации.
вектор
АгрегацияСледующий оператор получает вектор такого же размера. Промежуток — это один вектор, он целиком влезает в кэш процессора.

Это решает обе проблемы сразу. Накладные расходы на вызовы амортизируются: один вызов функции обрабатывает не одну строку, а 2048 — стоимость настройки фрейма делится на 2048. Интерпретация выражения тоже происходит раз на вектор, а не раз на строку: движок один раз «сообразил», что делать, и применил это к 2048 значениям плотным циклом. При этом память не переполняется: промежуточный результат — это всего один вектор из 2048 значений, крошечный по сравнению с полной колонкой, он целиком помещается в кэш процессора.

МодельНакладные расходы на вызовыПамять под промежуткиГде применяется
Tuple-at-a-timeОгромные — на каждую строкуМинимальнаяOLTP: SQLite, PostgreSQL
Full materializationМинимальныеОгромная — колонка целикомРанний MonetDB
Vector-at-a-timeМинимальные — амортизированы на 2048Маленькая — один векторDuckDB, Vectorwise

Почему середина выигрывает

Векторизация выигрывает не потому, что она «компромисс», а потому, что она снимает оба узких места одновременно, не создавая новых. Tuple-at-a-time экономит память, но платит за это вызовами. Full materialization экономит вызовы, но платит памятью. Vector-at-a-time экономит и то, и другое: вектор достаточно велик, чтобы амортизировать вызовы (2048 — много), и достаточно мал, чтобы поместиться в кэш (2048 значений — это килобайты, не гигабайты).

Есть и третий, более тонкий выигрыш. Плотный цикл по вектору из 2048 одинаковых значений — это код, который процессор любит. Такой цикл хорошо предсказывается, хорошо конвейеризируется, и компилятор может применить к нему SIMD — векторные инструкции процессора, обрабатывающие несколько значений за один такт. Построчная обработка с её ветвлениями и вызовами этого не позволяет. Векторный цикл превращает аналитику в работу, для которой современный процессор спроектирован.

NOTE

Название «векторизация» в DuckDB означает именно обработку порциями-векторами, а не обязательно использование SIMD-инструкций процессора. SIMD — приятное следствие: плотные векторные циклы хорошо ложатся на SIMD, и DuckDB этим пользуется. Но суть векторизации — в самой модели «порция фиксированного размера между операторами», и она даёт выигрыш даже без SIMD, просто за счёт амортизации накладных расходов.

Эта модель — фундамент всего движка DuckDB. Дальше в модуле мы разберём, почему вектор именно 2048 значений, как устроены Vector и DataChunk, какие бывают физические типы векторов и как push-based исполнение прогоняет векторы через дерево операторов. Но базовая идея уже здесь: данные двигаются порциями, и размер порции выбран так, чтобы одновременно убить накладные расходы и не переполнить кэш.


Попробуй сам

Это концептуальный урок — задания на размышление и наблюдение.

  1. Возьмите построчную СУБД (SQLite) и DuckDB. На одинаковом большом CSV-файле (несколько миллионов строк) выполните одинаковый агрегирующий запрос вида SELECT category, count(*), sum(amount) FROM data GROUP BY category. Сравните время. Объясните разницу через модели обработки.
  2. Прикиньте арифметику накладных расходов. Если вызов функции стоит условные 20 тактов, а полезная операция — 1 такт, во сколько раз построчная модель медленнее векторной на этой операции? Как меняется ответ, если вектор содержит 2048 значений?
  3. Объясните своими словами, почему полная материализация быстра по CPU, но проигрывает на больших данных. Какой ресурс становится узким местом?
  4. Подумайте: почему построчная модель (tuple-at-a-time) — разумный выбор для OLTP-СУБД, обслуживающей транзакции по одной-две строки, но плохой для аналитики?

ClickHouse: векторизованное исполнение — тот же принцип в column store
Проверка знанийKnowledge check
Какие три модели обработки данных существуют, и почему vector-at-a-time выигрывает у обеих альтернатив?
ОтветAnswer
Существуют три модели движения данных по конвейеру операторов. Первая — tuple-at-a-time (построчная, модель Volcano): одна строка проходит весь конвейер сверху донизу, потом следующая. Она экономит память, но платит огромными накладными расходами: на каждую строку — вызовы функций (настройка фрейма, передача аргументов, переход) и повторная интерпретация выражения. На миллионе строк полезная работа тонет в накладных расходах. Вторая — full materialization (полная материализация, ранний MonetDB): каждый оператор обрабатывает всю колонку и записывает полный результат в память. Накладные расходы на вызовы исчезают (один вызов на колонку, плотный цикл), но промежуточные результаты материализуются целиком — они переполняют кэш процессора и оперативную память, движок упирается в пропускную способность памяти. Третья — vector-at-a-time (векторная, выросла из MonetDB/X100 / Vectorwise, выбрана DuckDB): данные движутся порциями фиксированного размера — векторами примерно по 2048 значений. Vector-at-a-time выигрывает, потому что снимает оба узких места сразу, не создавая новых: вектор достаточно велик, чтобы амортизировать накладные расходы на вызовы и интерпретацию (один вызов и одна интерпретация на 2048 значений), и достаточно мал, чтобы промежуток-вектор целиком помещался в кэш процессора, не переполняя память. Дополнительный выигрыш — плотный цикл по вектору хорошо предсказывается процессором, конвейеризируется и допускает SIMD. Tuple-at-a-time экономит память ценой вызовов, full materialization экономит вызовы ценой памяти, а vector-at-a-time экономит и то, и другое.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Почему построчная модель обработки (tuple-at-a-time) катастрофически медленна на аналитических запросах?

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

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

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

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