«Все знают, что отладка вдвое сложнее, чем написание программы. Но если вы будете писать её максимально умно, то как вообще собираетесь отлаживать?», — Брайан Керниган

Оглавление

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

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

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

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

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

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

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

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

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

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

Катастрофа с красно-чёрным деревом

Компилятор тратил 60% времени на поиск символов. Не на парсинг, не на генерацию кода, просто на поиск в таблице символов.

Для типичной программы на встраиваемой системе с 10 тысячами символов это было неприемлемо. В таблице символов хранились имена переменных, имена функций и определения типов. В реализации использовалось красно-чёрное дерево — самобалансирующееся дерево двоичного поиска.

«У него O(log n); судя по учебникам, оно идеально подходит для этой цели», — сказал мой коллега.

Но профилировщик показывал иное:

$ perf stat -e cache-misses,instructions ./compiler test.c
  Performance counter stats:
    2,847,234 cache-misses
    8,500,000 instructions

2,8 миллиона промахов кэша на 8,5 миллиона команд? Получается один промах кэша на каждые три команды!

Я попробовал нечто безумное: заменил красно-чёрное дерево на отсортированный массив и двоичный поиск. Двоичный поиск тоже имеет O(log n), поэтому, теоретически, должен обладать той же скоростью.

Результат: компилятор стал в 3 раза быстрее.

Как два алгоритма O(log n) могут иметь столь разную производительность?

Расследование

Я прогнал обе реализации через perf, чтобы понять, что происходит:

# Версия с красно-чёрным деревом
$ perf stat -e cache-references,cache-misses,cycles ./compiler_rbtree test.c
  Performance counter stats:
    3,247,832  cache-references
    2,847,234  cache-misses  (87.7% miss rate)
   24,000,000  cycles

# Версия с отсортированным массивом
$ perf stat -e cache-references,cache-misses,cycles ./compiler_array test.c
  Performance counter stats:
    1,123,456  cache-references
      234,567  cache-misses  (20.9% miss rate)
    8,000,000  cycles

Вот и причина: 87,7% промахов кэша у красно-чёрного дерева и всего 20,9% у отсортированного массива.

В системе RISC-V каждый промах кэша стоит примерно 100 тактов. Красно-чёрное дерево основную часть своего времени тратило на ожидание памяти.

Что пишут в учебниках

На всех курсах лекций по структурам данных учат деревьям двоичного поиска. На первый взгляд, они выглядят привлекательно:

Дерево двоичного поиска (Binary Search Tree, BST):

  • Вставка: O(log n).

  • Поиск: O(log n).

  • Удаление: O(log n).

  • Обход по порядку даёт отсортированный порядок.

Сбалансированные деревья (AVL, красно-чёрные) гарантируют высоту O(log n) даже при конфликтующих входных данных.

Учебники делают такой вывод: «Используйте сбалансированные BST для динамических датасетов с частыми вставками и операциями поиска».

Кажется, это идеально подходит для таблицы символов, правда?

Проверка реальностью

Но учебники не говорят нам, что деревья двоичного поиска — это настоящий кошмар следования по указателям.

Каждый обход дерева выполняет переход в произвольный адрес памяти. Каждый переход с большой долей вероятности окажется промахом кэша.


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

Проблема заключается в схеме памяти.

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

При распределении массива все его элементы находятся в памяти друг за другом:

Память: [10][20][30][40][50][60][70][80]
          ↑   ↑   ↑   ↑   ↑   ↑   ↑   ↑
       0x1000 ...последовательно, удобно для кэша...

При доступе к array[4] CPU получает 64-байтную линию кэша, включающую в себя array[4], array[5], array[6] и так далее. Если дальше выполняется доступ к array[5], то этот элемент уже находится в памяти.

Дерево двоичного поиска: разбросанная память

При вставке узлов в BST каждый узел распределяется malloc() отдельно. В результате они оказываются разбросаны по куче:

       40 (@ 0x5000)
      /  \
    20    60 (@ 0x2000, @ 0x8000)
   /  \   /  \
  10  30 50  70 (@ 0x1000, @ 0x3000, @ 0x6000, @ 0x9000)

Каждый узел находится в отдельном адресе памяти. При следовании по указателям выполняется переход к произвольному адресу.

Поведение кэша: конкретный пример

Давайте поищем значение 70 в обеих структурах.

Отсортированный массив (двоичный поиск):

Шаг 1: проверка среднего элемента [40] @ 0x1020
  → ПРОМАХ кэша (100 тактов)
  → CPU получает линию кэша, содержащую [30][40][50][60]

Шаг 2: проверка [60] @ 0x1030
  → ПОПАДАНИЕ в кэш (1 такт) — уже находится в линии кэша!

Шаг 3: проверка [70] @ 0x1038
  → ПОПАДАНИЕ в кэш (1 такт) — всё ещё в кэше

Всего: ~102 такта, 1 промах кэша

Дерево двоичного поиска:

Шаг 1: проверка корня [40] @ 0x5000
  → ПРОМАХ кэша (100 тактов)
  → Получение линии кэша по адресу 0x5000

Шаг 2: переход вправо, проверка [60] @ 0x8000
  → ПРОМАХ кэша (100 тактов) — другой адрес памяти!

Шаг 3: переход вправо, проверка [70] @ 0x9000
  → ПРОМАХ кэша (100 тактов) — ещё один адрес!

Всего: ~300 тактов, 3 промаха кэша

В обоих алгоритмах одинаковое количество сравнений (3), но из-за промахов кэша BST в 3 раза медленнее.

Именно поэтому таблица символов компилятора была такой медленной. Каждая операция поиска символа следовала по указателям в разбросанной памяти.


Бенчмарк

Покажу вам реальный код, который я тестировал. Вот простая реализация BST:

// Узел дерева двоичного поиска
typedef struct bst_node {
    int key;
    void *value;
    struct bst_node *left;
    struct bst_node *right;
} bst_node_t;

void* bst_search(bst_node_t *root, int key) {
    while (root) {
        if (key == root->key) return root->value;
        root = (key < root->key) ? root->left : root->right;
    }
    return NULL;
}

А вот версия с отсортированным массивом:

typedef struct {
    int key;
    void *value;
} array_entry_t;

void* array_search(array_entry_t *arr, int n, int key) {
    int left = 0, right = n - 1;
    while (left <= right) {
        int mid = (left + right) / 2;
        if (arr[mid].key == key) return arr[mid].value;
        if (key < arr[mid].key) right = mid - 1;
        else left = mid + 1;
    }
    return NULL;
}

Я выполнил 10000 случайных операций поиска по датасетам разных размеров:

Датасет: 1000 элементов
  BST:                      2400 тактов на операцию поиска
  Отсортированный массив:    800 тактов на операцию поиска
  Ускорение: 30×

Датасет: 10000 элементов
  BST:                     3200 тактов на операцию поиска
  Отсортированный массив:  1100 тактов на операцию поиска
  Ускорение: 2,9×

Промахи кэша (статистика perf):
  BST:                      8,5 промаха на операцию поиска
  Отсортированный массив:   2,1 промаха на операцию поиска

Отсортированный массив стабильно быстрее в 3 раза быстрее, хоть оба решения имеют O(log n).

Почему отсортированный массив побеждает:

  1. Последовательная схема: при двоичном поиске доступ выполняется к элементам, которые с большой вероятностью находятся в одной линии кэша.

  2. Многократное использование линий кэша: при каждом промахе кэша загружается 8 элементов (64-байтная линия кэша ÷ 8-байтный элемент).

  3. Prefetcher помогает: аппаратный prefetcher может распознать шаговый паттерн и выполнять упреждающую выборку.

У BST нет ни одного этого преимущества. Каждое разыменование указателя оказывается лотереей.


Оверхед памяти

Узел BST (64-битная система):

struct bst_node {
    int key;           // 4 байта
    void *value;       // 8 байт
    struct bst_node *left;   // 8 байт
    struct bst_node *right;  // 8 байт
    // Заполнитель: 4 байта
};
// Всего: 32 байта на элемент

Элемент отсортированного массива:

struct array_entry {
    int key;     // 4 байта
    void *value; // 8 байт
    // Заполнитель: 4 байта (для выравнивания)
};
// Всего: 16 байт на элемент

Используемая память (1000 элементов):

  • BST: 32 КБ (32 байт × 1000)

  • Массив: 16 КБ (16 байт × 1000)

BST тратит в 2 раза больше памяти на указатели, что снижает скорость работы с кэшем.


Ну а как насчёт сбалансированных деревьев?

Вы можете подумать: «Да, если вставить отсортированные данные, то базовое BST может выродиться в связанный список. Но что насчёт сбалансированных деревьев, например, AVL или красно-чёрных деревьев? Они имеют гарантированную высоту O(log n)!»

Именно так говорил мой коллега, когда я предложил заменить красно-чёрное дерево на отсортированный массив.

Он был прав в том, что сбалансированные деревья решают проблему наихудшего случая. Если вставлять в базовое BST ключи в отсортированном порядке, то получится связанный список с высотой O(n). Сбалансированные деревья предотвращают это.

Но сбалансированные деревья не решают проблему кэша. Они всё равно выполняют следование по указателям в разбросанной памяти.

Красно-чёрные деревья

Красно-чёрное дерево в нашем компиляторе обеспечивало следующие инварианты:

  • Каждый узел или красный, или чёрный.

  • Корень чёрный.

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

  • Все пути от корня к листьям имеют одинаковое количество чёрных узлов.

Эти правила гарантируют, что высота дерева будет не больше 2×log₂(n).

Пи вставке или удалении дерево выполняет вращения для сохранения баланса:

Вращение вправо:
    y              x
   / \            / \
  x   C    →     A   y
 / \                / \
A   B              B   C

Вращения — это просто обновления указателей, то есть малозатратные с точки зрения операций CPU действия. Но они не устраняют фундаментальную проблему: каждый узел всё равно находится в произвольном месте памяти.

Проблема с кэшем сохраняется

Вот, какие показатели были у красно-чёрного дерева:

$ perf stat -e cache-misses,L1-dcache-load-misses ./compiler_rbtree test.c
  Performance counter stats:
    2,847,234 cache-misses
    2,654,123 L1-dcache-load-misses

Почти каждый обход дерева приводил к промаху кэша. Дерево сбалансировано, но оно всё равно медленное.

Сбалансированные деревья решают проблему алгоритмического наихудшего случая, но не аппаратного.


Так когда же использовать BST?

После замены красно-чёрного дерева сортированным массивом в нашем компиляторе мне задали вопрос: «Нужно ли вообще когда-нибудь использовать BST?»

Да. Но сценарии его применения более специфичны, чем рассказывается в учебниках.

1. Когда есть частые вставки и удаления

Отсортированный массив идеально подходил для таблицы символов нашего компилятора, потому что при компиляции символы в основном используются только для чтения. Мы определяем переменные в начале функции, а затем многократно ищем их.

Но что, если вставки и удаления происходят постоянно?

В случае отсортированного массива:

  • Вставка: O(n) — необходимо сдвинуть все элементы вправо.

  • Удаление: O(n) — необходимо сдвинуть все элементы влево.

В случае BST:

  • Вставка: O(log n) — просто обновить несколько указателей.

  • Удаление: O(log n) — просто обновить несколько указателей.

Я протестировал это на 1000 произвольных операций вставки/удаления:

Отсортированный массив:   12000 тактов/операция (оверхед на сдвиг)
Красно-чёрное дерево:      3500 тактов/операция
Ускорение: 3,4× для BST

При большом количестве вставок/удалений BST побеждают, несмотря на промахи кэша.

2. Когда нам нужны запросы в диапазоне

У BST есть удобное свойство: при обходе по порядку посещение ключей происходит в отсортированном порядке.

void inorder(bst_node_t *node, void (*visit)(int key)) {
    if (!node) return;
    inorder(node->left, visit);
    visit(node->key);
    inorder(node->right, visit);
}

Благодаря этому становятся эффективными запросы в диапазоне. Если нам нужны «все ключи от 100 до 200», то можно пропускать целые поддеревья, находящиеся вне этого диапазона.

В случае отсортированного массива мы находим 100 при помощи двоичного поиска, а затем выполняем линейное сканирование до 200. Если диапазон большой, это происходит медленнее.

3. Когда датасет маленький

При маленьких датасетах (< 100 элементов) потери от промахов кэша менее серьёзны:

Размер датасета: 50 элементов
  BST:                    180 тактов на операцию поиска
  Отсортированный массив: 150 тактов на операцию поиска
  Разница: всего 20% (не 3×)

Если элементов всего 50, многие узлы BST умещаются в кэш. Проблема следования по указателям менее серьёзна.

В случае малых датасетов используйте то, что проще реализовать. Разница в производительности не так важна.


Оптимизации: BST с учётом кэша

Косвенное двоичное дерево (на основе массива)

Принцип: хранение дерева в массиве при помощи индексной арифметики (например, двоичной кучи).

Схема:

Индекс:  0   1   2   3   4   5   6
Массив: [40][20][60][10][30][50][70]

Структура дерева:
       40 (индекс 0)
      /  \
    20    60 (индекс 1, 2)
   /  \   /  \
  10  30 50  70 (индекс 3, 4, 5, 6)

Родительский элемент i: (i-1)/2
Левый дочерний элемент:  2*i + 1
Правый дочерний элемент: 2*i + 2

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

  • Последовательная схема памяти.

  • Нет указателей (экономия 16 байт для каждого узла).

  • Удобство для кэша.

Недостатки:

  • Должно быть полным деревом (впустую тратит пространство, если не сбалансировано).

  • Вставка/удаление требует сдвига массива.

Когда использовать: для статических датасетов (сборка один раз, многократные запросы).

B-дерево

Более качественное решение: деревья с несколькими потомками (см. Главу 10)

  • Хранение нескольких ключей в узле.

  • Каждый узел умещается в линию кэша.

  • Снижается высота дерева и количество промахов кэша.


Пример из реального мира: красно-чёрные деревья ядра Linux

После того, как я заменил в нашем компиляторе красно-чёрное дерево на отсортированный массив, один коллега спросил: «Но в ядре Linux повсеместно используются красно-чёрные деревья. Разработчики ошибаются?».

Нет, они используют подходящий для своего случая инструмент.

В ядре Linux красно-чёрные деревья применяются для:

  • Планировщика процессов: отслеживания запускаемых процессов.

  • Виртуальной памяти: управления областями памяти.

  • Таймеров: планирования будущих событий.

Всё это нагрузки с большим объёмом операций записи и частыми вставками и удалениями. Процессы постоянно создаются и уничтожаются. Области памяти распределяются и освобождаются. Таймеры добавляются и удаляются.

Вот, как выглядит узел красно-чёрного дерева ядра (фрагмент lib/rbtree.c):

struct rb_node {
    unsigned long  __rb_parent_color;  // Указатель на родителя + бит цвета
    struct rb_node *rb_right;
    struct rb_node *rb_left;
} __attribute__((aligned(sizeof(long))));

Обратите внимание на оптимизацию: указатель на родителя и бит цвета скомбинированы в одном поле. Так как указатели выровнены в 8 байтах, нижние 3 бита всегда равны нулю. Ядро использует один из этих битов для хранения цвета (красный/чёрный). Это экономит по 8 байт на узел.

Я выполнил бенчмаркинг нагрузки, напоминающей планировщик (10000 операций вставки/удаления/поиска):

Красно-чёрное дерево:      2,1 мкс
Отсортированный массив:    8,5 мкс (слишком медленно для планировщика)

При такой нагрузке красно-чёрное дерево в 4 раз быстрее, потому что преобладают вставки и удаления.

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


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

Используйте отсортированный массив, если:

  • ✅ В основном выполняется поиск (активное чтение).

  • ✅ Датасет умещается в кэш (< 10000 элементов).

  • ✅ Обновления происходят нечасто.

Используйте дерево BST (красно-чёрное/AVL), если:

  • ✅ Происходят частые вставки/удаления.

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

  • ✅ Датасет слишком велик для сдвига массива.

Используйте B-дерево, если:

  • ✅ Работа идёт с крупными датасетами (> 10000 элементов).

  • ✅ Критична эффективность использования кэша.

  • ✅ Хранение на диске/SSD (см. Главу 10).

Избегайте BST, если:

  • ❌ Нагрузка подразумевает только поиск.

  • ❌ Датасет маленький (< 100 элементов) → используйте линейный поиск.

  • ❌ Требуется предсказуемая производительность → используйте хэш-таблицу.


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

Катастрофа с красно-чёрным деревом была устранена простым отсортированным массивом. Компилятор ускорился в 3 раза, а доля времени на поиск символов снизилась с 60% до 20%. Частота промахов кэша упала с 87,7% до 20,9%. Но это не значит, что использование BST всегда будет ошибкой — просто нужно понимать, что необходимо учитывать тип нагрузки.

Основные наблюдения:

  1. Деревья двоичного поиска неудобны для кэша. Каждое разыменование указателя скорее всего будет приводить к промаху кэша. При нагрузках, где активно применяется поиск, отсортированные массивы часто в 3 раза быстрее, несмотря на то, что имеют такую же сложность O(log n).

  2. Важно учитывать память. BST используют в 2 раза больше памяти, чем массивы (32 байта против 16 байт на элемент в 64-битных системах). Лишние указатели вредят и оптимальности использования кэша, и пропускной способности памяти.

  3. BST побеждают в случае нагрузок с большим количеством операций записи. Если вы постоянно выполняете вставки и удаления, то BST в 3-4 раза быстрее, чем отсортированные массивы, потому что в них не нужно сдвигать элементы.

  4. Сбалансированные деревья не решают проблемы с кэшем. Красно-чёрные деревья и АВЛ-деревья гарантируют высоту O(log n), но всё равно приводят к следованию по указателям по разбросанной памяти.

  5. Правильность выбора определяется типом нагрузки. В таблице символов нашего компилятора преобладали операции чтения (поиска), поэтому победили отсортированные массивы. В планировщике Linux преобладают операции записи (постоянные вставки/удаления), поэтому победили красно-чёрные деревья.

Показатели из нашего компилятора:

  • Красно-чёрное дерево: 2400 тактов на операцию поиска, 87,7% промахов кэша.

  • Отсортированный массив: 800 тактов на операцию поиска, 20,9% промахов кэша.

  • Ускорение: 3×.

Показатели из нагрузки с преобладающими операциями записи:

  • Красно-чёрное дерево: 3500 тактов на операцию.

  • Отсортированный массив: 12000 тактов на операцию.

  • Ускорение: 3,4× в пользу BST.

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