STANDARD_VECTOR_SIZE = 2048: почему столько
В предыдущем уроке мы установили: DuckDB обрабатывает данные порциями-векторами, и это компромисс между построчной обработкой и полной материализацией. Но любой компромисс требует конкретного числа. Насколько большой должна быть порция? Десять значений? Миллион? DuckDB отвечает: 2048. Это значение константы STANDARD_VECTOR_SIZE — размера вектора по умолчанию.
2048 — не случайное и не «круглое для красоты» число. Это инженерный выбор, зажатый между двумя противоположными требованиями, и понять его — значит понять, как векторизованный движок взаимодействует с физическим устройством процессора: его кэшами и его SIMD-инструкциями. Этот урок — про обоснование числа 2048 «до железа».
Два требования тянут в разные стороны
Размер вектора определяется конфликтом двух сил.
Сила первая — «вектор должен быть большим». Из прошлого урока: накладные расходы (вызов функции, интерпретация выражения) делятся на число значений в векторе. Чем больше вектор, тем меньше накладных расходов на одно значение. Вектор из 10 значений амортизирует вызов всего в 10 раз — мало. Вектор из 2048 амортизирует его в 2048 раз — накладные расходы становятся пренебрежимыми. Эта сила хочет, чтобы вектор был как можно больше.
Сила вторая — «вектор должен быть маленьким». Промежуточные результаты — это векторы, и они должны помещаться в кэш процессора. Кэш — это маленькая сверхбыстрая память между процессором и оперативной памятью. Если вектор и промежутки влезают в кэш, процессор работает на полной скорости. Если не влезают — каждое обращение к данным идёт в медленную оперативную память, и движок упирается в неё. Эта сила хочет, чтобы вектор был как можно меньше — иначе он вытесняется из кэша.
2048 — точка равновесия. Достаточно большой, чтобы накладные расходы исчезли. Достаточно маленький, чтобы рабочий набор векторов жил в кэше. Чтобы увидеть, почему равновесие именно здесь, надо посмотреть на кэши процессора в цифрах.
Иерархия кэшей процессора
Процессор не обращается к оперативной памяти напрямую при каждом действии — это было бы слишком медленно. Между ядром и RAM лежит иерархия кэшей, каждый уровень больше и медленнее предыдущего:
| Уровень | Типичный размер | Скорость доступа |
|---|---|---|
| Регистры | десятки значений | мгновенно |
| L1-кэш | примерно 32-48 КБ на ядро | несколько тактов |
| L2-кэш | примерно 256 КБ - 2 МБ на ядро | около десятка тактов |
| L3-кэш | несколько-десятки МБ, общий | десятки тактов |
| Оперативная память (RAM) | гигабайты | сотни тактов |
Ключевые цифры — порядки величин разрыва. Доступ к L1 — несколько тактов. Доступ к RAM — сотни тактов. Разница примерно в сто раз. Если данные, с которыми сейчас работает оператор, лежат в L1/L2 — процессор почти не простаивает. Если они вытеснены в RAM — процессор большую часть времени ждёт данные, а не считает.
Цель размера вектора — чтобы рабочий набор оператора жил в L1/L2-кэше. Посчитаем. Вектор из 2048 значений типа INTEGER (4 байта) — это 2048 умножить на 4, то есть 8 килобайт. Вектор BIGINT (8 байт) — 16 килобайт. Оператору обычно нужно несколько векторов сразу: входной, выходной, может быть промежуточный. Несколько векторов по 8-16 КБ — это десятки килобайт. Это укладывается в L1 (32-48 КБ) или с запасом в L2. Рабочий набор векторного оператора помещается в быстрый кэш — и процессор не простаивает.
Если бы вектор был, скажем, миллион значений, один такой вектор занял бы мегабайты — он не поместился бы ни в L1, ни в L2, и движок при каждой операции упирался бы в RAM. Это была бы фактически полная материализация со всеми её бедами. Если бы вектор был 16 значений — он легко влез бы в кэш, но накладные расходы амортизировались бы лишь в 16 раз, и движок захлебнулся бы в вызовах. 2048 — посередине: рабочий набор в кэше, накладные расходы амортизированы.
Связь с SIMD
У числа 2048 есть второе обоснование — SIMD. SIMD (Single Instruction, Multiple Data) — это режим процессора, в котором одна инструкция обрабатывает сразу несколько значений: например, одна инструкция складывает не два числа, а восемь пар чисел за один такт. Современные процессоры имеют SIMD-регистры, вмещающие 4, 8, 16 значений в зависимости от ширины регистра и типа данных.
SIMD требует, чтобы данные лежали плотным массивом одного типа — ровно так, как лежит вектор DuckDB. Плотный цикл по вектору из 2048 одинаковых значений компилятор может развернуть в SIMD-инструкции: вместо 2048 отдельных сложений — 2048, делённое на ширину SIMD-регистра, векторных сложений. Построчная обработка такого не позволяет: там значения перемежаются ветвлениями и вызовами.
Почему здесь важно именно 2048, а не произвольное число? 2048 — это степень двойки (2 в 11-й степени). Степень двойки идеально делится на любую ширину SIMD-регистра (которые тоже степени двойки: 4, 8, 16). Цикл по 2048 значениям раскладывается на целое число SIMD-итераций без остатка, без «хвоста» из необработанных значений, который пришлось бы обрабатывать отдельным скалярным кодом. Кратность степени двойки делает векторный цикл аккуратным для SIMD и для компилятора.
Степень двойки удобна не только для SIMD. Размеры структур, выравнивание данных в памяти, индексная арифметика — всё это работает аккуратнее, когда размер вектора степень двойки. 2048 — это инженерно «правильное» число: оно и попадает в нужный диапазон между кэшем и амортизацией, и при этом является степенью двойки, что упрощает всё, что связано с памятью и SIMD.
STANDARD_VECTOR_SIZE — константа компиляции
Важная деталь: STANDARD_VECTOR_SIZE — это не настройка, которую крутят в рантайме через SET. Это константа времени компиляции DuckDB. Значение 2048 зашито в собранный бинарник, и весь движок — все операторы, все структуры данных — оптимизирован именно под него.
Технически DuckDB можно собрать из исходников с другим STANDARD_VECTOR_SIZE. Но это делается крайне редко — разве что для экспериментов или для экзотических платформ. Для обычного пользователя 2048 — это данность, фиксированная характеристика движка. Знать число важно не чтобы его менять, а чтобы понимать поведение DuckDB: почему row group — это 122 880 строк (60 умножить на 2048, тема модуля про storage), почему планы оперируют тем, что они оперируют, как оценивать объёмы данных в движке.
Не пытайтесь «настроить» размер вектора через SET или PRAGMA — такой настройки нет, потому что 2048 это константа компиляции, а не рантайм-параметр. Если вы видите совет «увеличьте размер вектора для скорости» — это недоразумение. Производительность DuckDB настраивается другими рычагами (число потоков, лимит памяти), а размер вектора уже выбран оптимально под устройство процессоров и менять его не нужно.
Итог урока: 2048 — это инженерный компромисс, зажатый между тремя физическими фактами процессора. Достаточно велик, чтобы амортизировать накладные расходы на вызовы и интерпретацию. Достаточно мал, чтобы рабочий набор векторов помещался в L1/L2-кэш, а не вытеснялся в медленную RAM. И является степенью двойки, чтобы аккуратно ложиться на SIMD-инструкции и на работу с памятью. Это число — точка, где векторизованная модель максимально эффективно использует реальное железо.
Попробуй сам
Это урок про числа — задания на расчёт и проверку.
- Посчитайте размер вектора в байтах для разных типов: вектор из 2048 значений
TINYINT(1 байт),INTEGER(4 байта),BIGINT(8 байт),DOUBLE(8 байт). Сравните результаты с типичным размером L1-кэша (32-48 КБ). - Узнайте размер кэшей вашего процессора (на macOS —
sysctl -a | grep cachesize, на Linux —lscpu). Прикиньте, сколько векторовINTEGERпомещается в ваш L1 и L2. - Объясните своими словами, что было бы плохо, если бы
STANDARD_VECTOR_SIZEравнялся 64. А если бы он равнялся 1 миллиону? - 2048 — это 2 в 11-й степени. Возьмите ширину SIMD-регистра в 8 значений и убедитесь, что 2048 делится на 8 нацело. Объясните, почему отсутствие остатка важно для векторного цикла.
- Попробуйте найти в DuckDB настройку для размера вектора через
SELECT * FROM duckdb_settings(). Объясните, почему её там нет.