Сравниваем массивы, списки и хеш-таблицы на практике. Узнайте, когда теория O(n) не работает, и как правильно бенчмаркать код. Начните измерять!
Вы знаете, что поиск в массиве — это O(n), а в хеш-таблице — O(1). Но что если на практике массив из 50 элементов обходит хеш-таблицу? В этой статье я расскажу, как я построил бенчмаркинг-сьют на C++ и какие уроки извлек. Вы узнаете, почему теория и практика расходятся, и как правильно измерять производительность.
Я хотел создать учебный проект, который реализует динамические массивы, связные списки и хеш-таблицы с нуля, бенчмаркает операции вставки, поиска и удаления, находит точки пересечения производительности и экспортирует результаты в CSV. Звучит просто? Через четыре месяца у меня был кастомный трекер памяти, несколько стратегий хеширования, бутстрэп для доверительных интервалов и глубокое понимание кэшей CPU.
Первое архитектурное решение — общий интерфейс DataStructure:
class DataStructure {
public:
virtual void insert(int key, const std::string& value) = 0;
virtual bool search(int key, std::string& value) const = 0;
virtual bool remove(int key) = 0;
virtual size_t memory_usage() const = 0;
virtual std::string type_name() const = 0;
// ...
};
Это делает бенчмаркинг элегантным: можно писать обобщенный код для любой структуры:
for (auto& structure : structures) {
timer.start();
structure->insert(key, value);
timer.stop();
}
Но виртуальные вызовы имеют накладные расходы. В тесных циклах поиск по vtable накапливается. Я потратил выходные, думая, что хеш-таблица медленнее, чем ожидалось... пока не понял, что измеряю стоимость полиморфизма, а не самой структуры. Решение: оставил чистый интерфейс для харнесса, но внутри использовал шаблоны для критичных к производительности участков. Полиморфный интерфейс все равно оправдан для поддерживаемости и легкого добавления новых структур.
Мой первый таймер был наивным:
auto start = std::chrono::high_resolution_clock::now();
do_operation();
auto end = std::chrono::high_resolution_clock::now();
Числа прыгали: некоторые запуски были в 10 раз быстрее других. В чем дело?
Проблема 1: Прогрев важен. Первые запуски всегда медленнее, потому что кэши CPU холодные, а предсказатель ветвлений еще не обучился. Я добавил настраиваемый прогрев:
template <typename Func>
void warmup(size_t warmup_count, Func&& operation) {
for (size_t i = 0; i < warmup_count; ++i) {
operation();
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
Проблема 2: Выбросы разрушают среднее. Тот запуск, когда ОС решила запустить сборку мусора? Он все испортит. Я реализовал автоматическое обнаружение выбросов с помощью Z-оценок:
// Удаляем выборки, отклоняющиеся более чем на 2 стандартных отклонения
std::vector<duration> remove_outliers(const std::vector<duration>& data) const;
Проблема 3: Масштабирование частоты CPU. Современные процессоры постоянно повышают и понижают частоту. Я добавил опции для привязки к ядру CPU и (на Linux) попытку зафиксировать губернатор CPU.
Проблема 4: Нужно больше, чем среднее. Отчет только среднего ± стандартное отклонение недостаточен. Я реализовал:
JSON-вывод теперь включает метаданные об оборудовании, SHA коммита git и сид, использованный для случайных паттернов — потому что воспроизводимость важна.
Реализуя DynamicArray, я думал просто удваивать емкость при заполнении. Классическая амортизированная вставка O(1). Но потом задумался: а что если использовать рост 1.5x? А числа Фибоначчи? А фиксированные приращения? Я реализовал все четыре:
enum class GrowthStrategy {
MULTIPLICATIVE_2_0, // коэффициент роста 2.0
MULTIPLICATIVE_1_5, // коэффициент роста 1.5
FIBONACCI, // рост по Фибоначчи
ADDITIVE // фиксированный прирост
};
И вот результаты бенчмарков:
Интересный вывод: для большинства реальных задач, где вы не вставляете миллионы элементов, разница незначительна. Очевидный выбор — удвоение — обычно достаточен.
Я реализовал две стратегии хеширования: открытую адресацию (линейное пробирование) и метод цепочек. Я думал, что открытая адресация выиграет из-за лучшей локальности кэша. Реальность оказалась тоньше:
enum class HashStrategy {
OPEN_ADDRESSING,
SEPARATE_CHAINING
};
Открытая адресация выигрывает, когда:
Метод цепочек выигрывает, когда:
Проблема томбстоунов особенно коварна. При удалении из хеш-таблицы с открытой адресацией нельзя просто пометить ячейку как пустую — будущие пробы могли пропустить ее. Поэтому помечаем как «томбстоун», но слишком много томбстоунов ухудшают производительность так же, как и коллизии. Я добавил отслеживание количества проб на операцию:
double insert_probes_mean {0.0};
double search_probes_mean {0.0};
double remove_probes_mean {0.0};
Это сразу показало, когда хеш-функция распределяет плохо.
В начале я создал синглтон MemoryTracker, перехватывающий все выделения:
class MemoryTracker {
public:
void record_allocation(void* ptr, size_t size, const char* file, int line);
void record_deallocation(void* ptr);
bool check_leaks() const;
// ...
};
Вместе с кастомным TrackedAllocator<T>, подключаемым к STL, я мог ответить на вопросы: сколько памяти реально использует каждая структура? Каков пик памяти во время бенчмарка? Есть ли утечки? Цифры накладных расходов памяти оказались поучительными:
| Структура | 10K элементов | Память | Накладные расходы |
|---|---|---|---|
| DynamicArray | 10,000 | ~240 KB | ~24 байта/элемент |
| SinglyLinkedList | 10,000 | ~400 KB | ~40 байт/элемент |
| HashMap (OA) | 10,000 | ~380 KB | ~38 байт/элемент |
| HashMap (SC) | 10,000 | ~520 KB | ~52 байта/элемент |
Связные списки имеют почти в два раза большие накладные расходы по сравнению с массивами, если учесть указатели на узлы. С оптимизацией пула памяти (MemoryPool<Node>) я снизил это, но массивы все равно выигрывают по плотности.
Нет ничего более раздражающего, чем «вчера было быстрее». Чтобы сделать бенчмарки воспроизводимыми, я добавил:
struct BenchmarkConfig {
enum class Pattern { SEQUENTIAL, RANDOM, MIXED };
Pattern pattern = Pattern::SEQUENTIAL;
std::optional<unsigned long long> seed;
bool seed_was_generated = false;
// ...
};
Теперь, когда кто-то сообщает странные цифры, я могу спросить: «Какой сид? Какой паттерн? Какое оборудование?» — и реально воспроизвести проблему.
После всей этой инфраструктуры я наконец мог ответить на вопрос: когда одна структура обходит другую? Анализ пересечений проходит по размерам и находит, где кривые производительности пересекаются:
Операция: поиск
Точка пересечения массива и хеш-таблицы при N ≈ 150
(При менее 150 элементах линейный поиск по массиву быстрее, чем поиск по хешу!)
Да, для небольших коллекций накладные расходы на хеширование часто превышают выгоду. Хеш-таблица с 50 элементами может быть медленнее линейного сканирования массива. Локальность кэша — вот что важно.
DataStructure упростил расширение и тестирование.hashbrowns — открытый исходный код, созданный для обучения:
git clone https://github.com/aeml/hashbrowns.git
cd hashbrowns
scripts/build.sh -t Release --test
./build/hashbrowns --size 10000 --runs 10 --structures array,hashmap
Код подробно прокомментирован, есть туториалы по добавлению собственных структур данных. Иногда лучший способ понять производительность — измерить ее самостоятельно.
Какие предположения о структурах данных вас удивляли? Поделитесь в комментариях — мне интересно узнать о ваших бенчмарк-приключениях!
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →