«Цель вычислений — понимание, а не числа», — Ричард Хэмминг

Оглавление

Глава 1: Разрыв в производительности

Глава 2: Иерархия памяти

Глава 3: Бенчмаркинг и профилирование

Глава 4: Массивы и локальность кэша

Глава 5: Связанные списки — убийцы кэша

Глава 6: Стеки и очереди

Глава 7: Хэш-таблицы и конфликты кэша

Глава 8: Динамические массивы и управление памятью

Глава 9: Двоичные деревья поиска

Глава 10: B-деревья и деревья, эффективно использующие кэш

Глава 11: Префиксные деревья и базисные деревья

Загадка базы данных

Вся наша база данных находилась в памяти, однако операции поиска по ней занимали 12 тысяч тактов. При миллионе показаний датчика IoT-устройства с 64 КБ кэша реализация красно-чёрного дерева оказалась слишком медленной для запросов в реальном времени.

«Давайте попробуем B-дерево», — предложил я.

«Разве они нужны не только для баз данных на дисках? — спросил лид, — У нас всё находится в памяти. Чем нам будет полезно B-дерево?»

Вопрос был вполне разумным. B-деревья были придуманы для доступа к диску; каждый узел в них — это блок диска. Однако паттерны промахов кэша выглядели подозрительно похожими на паттерны дискового ввода-вывода — всего в 100 раз, а не в 100000 раз быстрее.

В итоге мы реализовали B-дерево. Результаты удивили всех:

$ perf stat -e cache-misses,cycles ./db_query_rbtree
  Performance counter stats:
    18,500,000 cache-misses
   120,000,000 cycles

$ perf stat -e cache-misses,cycles ./db_query_btree
  Performance counter stats:
     2,800,000 cache-misses
    18,000,000 cycles

B-дерево оказалось в 6,7 раза быстрее красно-чёрного. Количество промахов кэша снизилось с 18,5 миллиона до 2,8 миллиона.

Почему? У B-дерева всего 3 уровня, а у красно-чёрного — 20 уровней. Меньше уровней = меньше промахов кэша.

Проблема двоичных деревьев

Из Главы 9 мы узнали, что двоичные деревья поиска страдают следованием по указателям. Каждый узел — это произвольное место в памяти, поэтому каждый этап обхода — это промах кэша.

Но есть и более глубокая проблема: высота деревьев.

При одном миллионе элементов:

  • Высота двоичного дерева: log₂(1000000) ≈ 20 уровней

  • Каждая операция поиска: разыменование 20 указателей

  • Промахи кэша: ~18-20 (почти каждый узел — это промах)

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

Вывод: большинство промахов кэша вызвано высотой дерева, а не доступом к отдельным узлам.

Решение: снизить высоту, увеличив показатель ветвления.


Что такое B-дерево?

B-дерево похоже на двоичное дерево поиска, но у каждого узла может быть множество дочерних элементов, а не два.

Вот простой пример с порядком 4 (максимум 3 ключа на узел):

                [40|80]
               /   |   \
         [10|20] [50|60] [90|100]

Корень имеет 2 ключа (40 и 80) и 3 дочерних элемента. Каждый элемент — это тоже узел со множеством ключей.

Основные свойства:

  • Каждый узел содержит до M-1 ключей (M — это «порядок»).

  • Каждый внутренний узел имеет до M дочерних элементов.

  • Все листья находятся на одной глубине (дерево сбалансировано).

  • Ключи в каждом узле отсортированы.

Для нашей базы данных IoT мы использовали порядок 64. Это означает следующее:

  • Каждый узел имеет до 63 ключей.

  • Каждый внутренний узел имеет до 64 дочерних элементов.

  • Высота дерева: log₆₄(1000000) ≈ 3 уровня.


Почему B-деревья удобны для кэша

Магия B-деревьев заключается в том, что все ключи узла хранятся в памяти последовательно.

Вот структура узла, которую я использовал:

#define BTREE_ORDER 64

typedef struct btree_node {
    int num_keys;                    // 4 байта
    int keys[BTREE_ORDER - 1];       // 252 байта (63 ключа)
    void *values[BTREE_ORDER - 1];   // 504 байта
    struct btree_node *children[BTREE_ORDER];  // 512 байт
    // Итого: ~1272 байта (умещается в ~20 линий кэша)
} btree_node_t;

При доступе к узлу все 63 ключа находятся в сплошном массиве. Можно выполнять двоичный поиск по ним без следования по указателям:

int find_key(btree_node_t *node, int key) {
    // Двоичный поиск в отсортировнном массиве (удобный для кэша!)
    int left = 0, right = node->num_keys - 1;
    while (left <= right) {
        int mid = (left + right) / 2;
        if (node->keys[mid] == key) return mid;
        if (key < node->keys[mid]) right = mid - 1;
        else left = mid + 1;
    }
    return -1;  // Не найдено
}

Поведение кэша:

  • Первый доступ к узлу: 1 промах кэша (загружает узел в кэш).

  • Двоичный поиск внутри узла: 0 дополнительных промахов кэша (все ключи последовательные).

  • Итого: 1 промах кэша на один уровень дерева.

Так как уровней всего 3, на одну операцию поиска приходится всего 3 промаха кэша!

Поиск по B-дереву

void* btree_search(btree_node_t *root, int key) {
    btree_node_t *node = root;
    
    while (node) {
        // Двоичный поиск в узле (удобный для кэша)
        int i = 0;
        while (i < node->num_keys && key > node->keys[i]) {
            i++;
        }
        
        // Значение найдено?
        if (i < node->num_keys && key == node->keys[i]) {
            return node->values[i];
        }
        
        // Узел-лист?
        if (!node->children[0]) {
            return NULL;  // Не найдено
        }
        
        // Спускаемся к дочернему элементу (здесь промах кэша)
        node = node->children[i];
    }
    
    return NULL;
}

Сложность:

  • Высота дерева: O(log_M N)

  • Поиск внутри узла: O(log M)

  • Итого: O(log M × log_M N) = O(log N)

Промахи кэша: O(log_M N) (по одному на уровень)

Результаты бенчмарка

Я протестировал разные порядки B-деревьев для нашей базы данных IoT с одним миллионом показаний датчика:

Датасет: 1 000 000 элементов, 10 000 случайных операций поиска

Красно-чёрное дерево:
  Высота: 20 уровней
  Такты/операции поиска: 12 000
  Промахи кэша: 18,5

B-дерево (порядок 16):
  Высота: 5 уровней
  Такты/операции поиска: 3 200
  Промахи кэша: 4,8
  Ускорение: 3,75×

B-дерево (порядок 64):
  Высота: 3 уровня
  Такты/операции поиска: 1 800
  Промахи кэша: 2,8
  Ускорение: 6,7×

B-дерево (порядок 256):
  Высота: 2 уровня
  Такты/операции поиска: 1 200
  Промахи кэша: 1,9
  Ускорение: 10×

В нашем случае идеально подходило B-дерево порядка 64 — оно оказалось в 6,7 раза быстрее красно-чёрного дерева.

Почему это сработало:

  1. Меньше уровней: 3 против 20, а значит, 3 промаха кэша против 20.

  2. Последовательные ключи: двоичный поиск внутри каждого узла удобен для кэша.

  3. Амортизированные затраты: затраты на поиск внутри узла (log₆₄ ≈ 6 сравнений) очень малы по сравнению с затратами на промах кэша (100 тактов).

Выбор порядка B-дерева

Компромисс: Больше порядок → меньше уровней, но больше сравнений на узел.

Оптимальный порядок: при котором узел умещается в одну линию кэша (64 байта).

Анализ линий кэша

Порядок 4 (3 ключа):

struct btree_node {
    int num_keys;        // 4 байта
    int keys[3];         // 12 байт
    void *values[3];     // 24 байта
    void *children[4];   // 32 байта
    // Итого: 72 байта (2 линии кэша)
};

Порядок 8 (7 ключей):

struct btree_node {
    int num_keys;        // 4 байта
    int keys[7];         // 28 байт
    void *values[7];     // 56 байт
    void *children[8];   // 64 байта
    // Итого: 152 байта (3 линии кэша)
};

Рекомендация:

  • Хранящееся в памяти B-дерево: порядок 16-64 (нужно уравновесить высоту с размером узла).

  • B-дерево с хранением на диске: порядок 128-512 (минимизирует поиск по диску).

Вставка в B-дерево

Сложность: поддержание сбалансированности (все листья на одной высоте).

Стратегия: разделение заполненных узлов.

Алгоритм вставки

void btree_insert(btree_node_t **root, int key, void *value) {
    btree_node_t *node = *root;

    // Если корень заполнен, разделяем его
    if (node->num_keys == BTREE_ORDER - 1) {
        btree_node_t *new_root = create_node();
        new_root->children[0] = node;
        split_child(new_root, 0);
        *root = new_root;
    }

    insert_non_full(*root, key, value);
}

void insert_non_full(btree_node_t *node, int key, void *value) {
    int i = node->num_keys - 1;

    if (!node->children[0]) {  // Узел-лист
        // Сдвигаем ключи, чтобы освободить место
        while (i >= 0 && key < node->keys[i]) {
            node->keys[i + 1] = node->keys[i];
            node->values[i + 1] = node->values[i];
            i--;
        }
        node->keys[i + 1] = key;
        node->values[i + 1] = value;
        node->num_keys++;
    } else {  // Внутренний узел
        // Находим дочерний элемент для опускания
        while (i >= 0 && key < node->keys[i]) {
            i--;
        }
        i++;

        // Разделяем дочерний элемент, если он заполнен
        if (node->children[i]->num_keys == BTREE_ORDER - 1) {
            split_child(node, i);
            if (key > node->keys[i]) i++;
        }

        insert_non_full(node->children[i], key, value);
    }
}

Разделение узлов

Пример (порядок 4, максимум 3 ключа):

До разделения:
  Узел: [10|20|30]  (заполнен)

После разделения:
  Левый:  [10]
  Родительский: [20]
  Правый: [30]

Затраты: O(M) на разделение (копирование ключей), но амортизированное O(1) (происходит редко).

Деревья B+: оптимизация под запросы диапазонов

Проблема B-дерева: значения разбросаны по всем уровням.

Дерево B+: все значения находятся в листьях, внутренние узлы хранят только ключи.

Структура:

Внутренние узлы (только ключи):
                [40|80]
               /   |   \
Узлы-листья (ключи + значения):
  [10:v1|20:v2|30:v3] → [40:v4|50:v5|60:v6] → [80:v7|90:v8|100:v9]
   ↑                      ↑                      ↑
   └──────────────────────┴──────────────────────┘
    Связанный список для сканирования диапазонов

Преимущества:

  1. Запросы по диапазонам: сканирование связанного списка листьев (последовательный доступ).

  2. Более широкое разветвление: внутренние узлы меньше (нет значений).

  3. Все данные хранятся в листьях: упрощает код.

Сценарий использования: базы данных (MySQL InnoDB, PostgreSQL, SQLite).

B-деревья, не зависящие от кэша

Проблема: оптимальный порядок B-дерева зависит от размера линии кэша (64 байта на x86, 128 байт на некоторых ARM).

Не зависящее от кэша B-дерево: адаптируется под любой размер кэша, не требуя настройки.

Принцип: рекурсивная схема (схема ван Эмде Боаса).

Пример (16 ключей):

Схема памяти:
[8]     [4|12]   [2|6|10|14] [1|3|5|7|9|11|13|15]
 ↑         ↑          ↑               ↑
Корень Уровень 1  Уровень 2        Листья

Последовательное в памяти, но логически устроено, как дерево

Преимущество: хорошо работает с разными размерами кэша.

Недостаток: сложная реализация, сложнее изменять.

Пример из реальной жизни: B-дерево SQLite

Сценарий использования: встроенная база данных (браузеры, мобильные приложения).

Архитектура:

  • Размер страницы: 4 КБ (соответствует размеру блока файловой системы).

  • Порядок: ~340 (4 КБ / 12 байт на элемент).

  • Дерево B+: все данные находятся в листьях.

Оптимизация: кэш страниц в памяти.

Бенчмарк (1 миллион элементов):

Поиск:
  В памяти:  1 200 тактов (3 уровней, все находятся в кэше)
  На диске:    8 мс (3 операции дискового поиска)

Сканирование диапазона (1000 элементов):
  В памяти:  180 000 тактов (последовательное сканирование листьев)
  На диске:    12 мс (последовательное чтение с диска)

Зачем использовать B-дерево в случае диска:

  • Минимизация операций поиска: 3 операции против 20 (BST).

  • Последовательное чтение: узлы-листья связаны.

  • Выравнивание по странице: каждый узел = один блок диска.

Встраиваемые системы: B-деревья фиксированного размера

Сложность: во встраиваемых системах нет динамического распределения.

Решение: заранее распределить узлы B-дерева в массиве.

#define MAX_NODES 1024
#define BTREE_ORDER 16

typedef struct {
    int num_keys;
    int keys[BTREE_ORDER - 1];
    void *values[BTREE_ORDER - 1];
    uint16_t children[BTREE_ORDER];  // Индексы, а не указатели
} btree_node_t;

typedef struct {
    btree_node_t nodes[MAX_NODES];
    uint16_t root;
    uint16_t free_list;
} btree_t;

Преимущества:

  • Без malloc: предсказуемый объём используемой памяти.

  • Удобство для кэша: узлы находятся в сплошном массиве.

  • Индексы вместо указателей: экономит память (2 байта против 8 байтов).

Недостаток: фиксированная ёмкость (MAX_NODES).

Рекомендации

B-дерево следует использовать в случае:

  • ✅ Больших датасетов (> 10000 элементов).

  • ✅ Частых вставок/удалений.

  • ✅ Необходимости запросов по диапазонам.

  • ✅ Хранения на диске/SSD.

Дерево B+ следует использовать в случае:

  • ✅ Индексации базы данных.

  • ✅ Частого сканирования диапазонов.

  • ✅ Когда все данные могут находиться в листьях.

BST следует использовать в случае:

  • ✅ Малых датасетов (< 1000 элементов).

  • ✅ Необходимости простой реализации.

Отсортированный массив следует использовать в случае:

  • ✅ Только чтения или редких обновлений.

  • ✅ Датасета, умещающегося в кэше.

Методики оптимизации

1. Групповая загрузка

Проблема: вставка отсортированных данных по отдельности происходит медленно.

Решение: строить B-дерево снизу вверх.

btree_t* bulk_load(int *keys, void **values, int n) {
    // Сортируем входные данные
    qsort_pairs(keys, values, n);

    // Создаём листья
    int num_leaves = (n + BTREE_ORDER - 2) / (BTREE_ORDER - 1);
    btree_node_t *leaves = build_leaves(keys, values, n);

    // Строим внутренние уровни снизу вверх
    while (num_leaves > 1) {
        leaves = build_level(leaves, num_leaves);
        num_leaves = (num_leaves + BTREE_ORDER - 1) / BTREE_ORDER;
    }

    return leaves;  // Корень
}

Ускорение: в 10-100 раз быстрее, чем отдельные вставки.

2. Сжатие префиксов

Наблюдение: у ключей часто бывают общие префиксы (например, URL, пути к файлам).

Оптимизация: сохранять общий префикс один раз на узел.

struct compressed_node {
    char prefix[32];         // Общий префикс
    int prefix_len;
    char suffixes[BTREE_ORDER][32];  // Только уникальные части
};

Экономия: снижение объёма занимаемой памяти на 50-80% для ключей-строк.

3. SIMD-поиск

Идея: использовать SIMD для сравнения ключей со множеством ключей узлов параллельно.

#include <immintrin.h>

int simd_search(int *keys, int n, int target) {
    __m256i target_vec = _mm256_set1_epi32(target);

    for (int i = 0; i < n; i += 8) {
        __m256i keys_vec = _mm256_loadu_si256((__m256i*)&keys[i]);
        __m256i cmp = _mm256_cmpeq_epi32(keys_vec, target_vec);
        int mask = _mm256_movemask_epi8(cmp);
        if (mask) {
            return i + __builtin_ctz(mask) / 4;
        }
    }
    return -1;
}

Ускорение: в 2-3 раза для крупных узлов (порядок 64+).

Подведём итог

Загадка базы данных была решена. B-дерево обеспечило скорость запросов в 6,7 раз выше, чем красно-чёрное дерево, снизив время поиска с 12000 до 1800 тактов. Количество промахов кэша снизилось с 18,5 до 2,8 на операцию поиска. Теперь IoT-устройство может с лёгкостью обрабатывать запросы датчика в реальном времени. Структура данных, предназначенная «только для диска», оказалась идеальной для оптимизации кэша базы данных, хранящейся в памяти.

Ключевые выводы:

  1. Высота дерева более важна, чем сложность узлов. B-дерево с 3 уровнями побеждает двоичное дерево с 20 уровнями даже несмотря на то, что для поиска в узле B-дерева требуется больше сравнений.

  2. Крайне важна последовательная схема памяти. Благодаря хранению всех ключей в узле последовательно двоичный поиск внутри узла становится удобным для кэша. При единственном промахе кэша загружается весь узел.

  3. B-деревья подходят не только для диска. В учебниках рассказывают, что B-деревья предназначены для баз данных на диске, но в случае больших датасетов они столь же ценны для структур данных в памяти.

  4. Важен порядок. Если он слишком маленький (порядок 4-8), то высота снизится недостаточно. Если слишком большой (порядок 256+), то узлы не уместятся в кэш. Для B-деревьев в памяти идеальный порядок находится в диапазоне 16-64.

  5. Для запросов по диапазонам лучше деревья B+. Благодаря хранению всех данных в листьях и их связыванию можно сканировать диапазоны последовательно без обхода дерева.

Показатели для нашей базы данных IoT:

  • Красно-чёрное дерево: 12000 тактов/поиск, 18,5 промаха кэша.

  • B-дерево (порядок 64): 1800 тактов/поиск, 2,8 промаха кэша.

  • Ускорение: 6,7×.

В следующей главе: префиксные деревья и базисные деревья для сопоставления префиксов и строковых ключей.