Boost:: flat_map и его производительность по сравнению с картой и unordered_map

Общеизвестно в программировании, что местность памяти значительно повышает производительность из-за попадания в кеш. Недавно я узнал о boost::flat_map, который представляет собой векторную реализацию карты. Похоже, он не так популярен, как ваш типичный map/unordered_map, поэтому мне не удалось найти сравнение производительности. Как он сравнивается и каковы наилучшие варианты использования?

Спасибо!

Ответ 1

Я недавно провел сравнительный анализ различных структур данных в моей компании, поэтому я чувствую, что мне нужно сказать пару слов. Очень сложно правильно что-то тестировать.

Бенчмаркинг

В Интернете мы редко находим (если вообще) хорошо спроектированный тест. До сегодняшнего дня я обнаружил только те тесты, которые были сделаны журналистом (довольно быстро и под десятком переменных).

1) Нужно подумать о разогреве кеша

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

Правда в том, что в реальном мире это не имеет особого смысла, потому что ваш кеш не будет теплым, и ваша операция, вероятно, будет вызвана только один раз. Поэтому вам нужно тестировать с помощью RDTSC и время, вызывающее их только один раз. Intel подготовила документ, описывающий, как использовать RDTSC (используя инструкцию cpuid для очистки конвейера и вызывая ее как минимум 3 раза в начале программы для ее стабилизации).

2) мера точности RDTSC

Я также рекомендую сделать это:

u64 g_correctionFactor;  // number of clocks to offset after each measurement to remove the overhead of the measurer itself.
u64 g_accuracy;

static u64 const errormeasure = ~((u64)0);

#ifdef _MSC_VER
#pragma intrinsic(__rdtsc)
inline u64 GetRDTSC()
{
    int a[4];
    __cpuid(a, 0x80000000);  // flush OOO instruction pipeline
    return __rdtsc();
}

inline void WarmupRDTSC()
{
    int a[4];
    __cpuid(a, 0x80000000);  // warmup cpuid.
    __cpuid(a, 0x80000000);
    __cpuid(a, 0x80000000);

    // measure the measurer overhead with the measurer (crazy he..)
    u64 minDiff = LLONG_MAX;
    u64 maxDiff = 0;   // this is going to help calculate our PRECISION ERROR MARGIN
    for (int i = 0; i < 80; ++i)
    {
        u64 tick1 = GetRDTSC();
        u64 tick2 = GetRDTSC();
        minDiff = Aska::Min(minDiff, tick2 - tick1);   // make many takes, take the smallest that ever come.
        maxDiff = Aska::Max(maxDiff, tick2 - tick1);
    }
    g_correctionFactor = minDiff;

    printf("Correction factor %llu clocks\n", g_correctionFactor);

    g_accuracy = maxDiff - minDiff;
    printf("Measurement Accuracy (in clocks) : %llu\n", g_accuracy);
}
#endif

Это измеритель расхождений, и он будет принимать минимум всех измеренных значений, чтобы время от времени не получать -10 ** 18 (64-битные первые отрицательные значения).

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

3) параметры

Последняя проблема - люди обычно проверяют слишком мало вариантов сценария. На производительность контейнера влияют:

  1. Распределитель
  2. размер содержимого типа
  3. стоимость реализации операции копирования, операции присваивания, операции перемещения, операции построения вложенного типа.
  4. количество элементов в контейнере (размер задачи)
  5. тип имеет тривиальное значение 3. -operations
  6. тип POD

Точка 1 важна, потому что контейнеры время от времени распределяются, и очень важно, если они распределяются с использованием CRT "new" или какой-либо определенной пользователем операции, такой как выделение пула, freelist или другое...

(для людей, интересующихся pt 1, присоединяйтесь к загадочной теме на gamedev о влиянии на производительность системного распределителя)

Суть 2 заключается в том, что некоторые контейнеры (скажем, A) будут терять время на копирование, и чем больше тип, тем больше накладные расходы. Проблема заключается в том, что при сравнении с другим контейнером B A может выиграть у B для небольших типов и проиграть для более крупных типов.

Точка 3 такая же, как точка 2, за исключением того, что она умножает стоимость на некоторый весовой коэффициент.

Точка 4 - это вопрос большого О, смешанного с проблемами кэша. Некоторые контейнеры с плохой сложностью могут значительно превосходить контейнеры с низкой сложностью для небольшого количества типов (например, map против vector, потому что их расположение в кэше хорошее, но map фрагментирует память). И затем в некоторой точке пересечения они проиграют, потому что общий размер начинает "просачиваться" в основную память и вызывать пропадание кеша, плюс тот факт, что асимптотическая сложность может начать ощущаться.

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

Точка 6, так же как и точка 5, POD могут извлечь выгоду из того факта, что конструкция копии является просто memcpy, и некоторые контейнеры могут иметь конкретную реализацию для этих случаев, используя частичную специализацию шаблонов, или SFINAE для выбора алгоритмов в соответствии с признаками T.

О плоской карте

Очевидно, что плоская карта представляет собой отсортированную векторную оболочку, как и Loki AssocVector, но с некоторыми дополнительными модернизациями, идущими с С++ 11, использующими семантику перемещения для ускорения вставки и удаления отдельных элементов.

Это все еще заказанный контейнер. Большинству людей обычно не нужна заказывающая часть, поэтому наличие unordered..

Вы flat_unorderedmap что, возможно, вам нужна flat_unorderedmap? что-то вроде google::sparse_map или что-то в этом роде - хэш-карта с открытым адресом.

Проблема хеш-карт с открытым адресом состоит в том, что во время rehash им приходится копировать все вокруг на новую расширенную плоскую землю, тогда как стандартная неупорядоченная карта просто должна воссоздать хеш-индекс, а выделенные данные остаются там, где они есть. Недостатком конечно является то, что память фрагментирована как ад.

Критерий перефразирования в хэш-карте с открытым адресом - это когда емкость превышает размер вектора сегмента, умноженного на коэффициент загрузки.

Типичный коэффициент нагрузки составляет 0.8; поэтому вам следует позаботиться об этом, если вы можете предварительно изменить размер своей хэш-карты перед ее заполнением, всегда предварительно измените ее размер на: intended_filling * (1/0.8) + epsilon это даст вам гарантию того, что вам никогда не придется суетно перефразировать и переписать все во время заполнения.

Преимущество закрытых карт адресов (std::unordered..) состоит в том, что вам не нужно заботиться об этих параметрах.

Но boost::flat_map - упорядоченный вектор; следовательно, она всегда будет иметь логарифмическую (N) асимптотическую сложность, которая менее хороша, чем хэш-карта с открытым адресом (амортизированное постоянное время). Вы должны рассмотреть это также.

Результаты тестов

Это тест с участием разных карт (с ключом int и значением __int64/somestruct) и std::vector.

проверенные типы информации:

typeid=__int64 .  sizeof=8 . ispod=yes
typeid=struct MediumTypePod .  sizeof=184 . ispod=yes

вставка

РЕДАКТИРОВАТЬ:

Мои предыдущие результаты включали ошибку: они фактически тестировали упорядоченную вставку, которая демонстрировала очень быстрое поведение для плоских карт.
Я оставил эти результаты позже на этой странице, потому что они интересны.
Это правильный тест: enter image description here

enter image description here

Я проверил реализацию, здесь нет такой вещи, как отложенная сортировка, реализованная на плоских картах. Каждая вставка сортируется на лету, поэтому этот тест демонстрирует асимптотические тенденции:

карта: O (N * log (N))
хешмапы: O (N)
векторные и плоские карты: O (N * N)

Предупреждение: здесь и далее 2 теста для std::map и оба flat_map содержат ошибки и фактически тестируют упорядоченную вставку (против случайной вставки для других контейнеров. Да, это сбивает с толку извините):
random insert of 100 elements without reservation

Мы видим, что упорядоченная вставка приводит к обратному нажатию и является чрезвычайно быстрой. Однако из не отмеченных в графике результатов моего теста я также могу сказать, что это не близко к абсолютной оптимальности для обратной вставки. При 10 тыс. Элементов идеальная оптимальность обратной вставки достигается для предварительно зарезервированного вектора. Что дает нам 3 миллиона циклов; мы наблюдаем 4.8M здесь для упорядоченной вставки в flat_map (следовательно, 160% от оптимального).

random insert of 10000 elements without reservation Анализ: помните, что это "случайная вставка" для вектора, поэтому огромные 1 миллиард циклов происходят из-за необходимости сдвигать половину (в среднем) данных вверх (один элемент на один элемент) при каждой вставке.

Случайный поиск 3 элементов (часы перенормированы на 1)

в размере = 100

rand search within container of 100 elements

в размере = 10000

rand search within container of 10000 elements

итерация

больше 100 (только тип MediumPod)

Iteration over 100 medium pods

больше 10000 (только тип MediumPod)

Iteration over 10000 medium pods

Конечное зерно соли

В конце я хотел вернуться к "Benchmarking §3 Pt1" (распределитель системы). В недавнем эксперименте, который я проводил для оценки производительности хэш-карты открытых адресов, я измерил разрыв в производительности более 3000% между Windows 7 и Windows 8 в некоторых случаях использования std::unordered_map (обсуждается здесь).
Что заставляет меня хотеть предупредить читателя о вышеупомянутых результатах (они были сделаны на Win7): ваш пробег может отличаться.

с уважением

Ответ 2

Из документов это похоже на аналогичный Loki::AssocVector, который я довольно тяжелый пользователь. Поскольку он основан на векторе, он имеет характеристики вектора, то есть:

  • Итераторы становятся недействительными всякий раз, когда size выходит за пределы capacity.
  • Когда он превышает capacity, ему необходимо перераспределить и переместить объекты, т.е. вставка не гарантируется постоянным временем, за исключением специального случая вставки в end, когда capacity > size
  • Поиск быстрее, чем std::map из-за локальности кэша, двоичный поиск, который имеет те же рабочие характеристики, что и std::map иначе
  • Использует меньше памяти, потому что это не связанное двоичное дерево
  • Он никогда не сжимается, если вы не произнесете его принудительно (поскольку это приводит к перераспределению)

Лучшее использование - это когда вы заранее знаете количество элементов (так что вы можете reserve upfront), или когда вставка/удаление происходит редко, но поиск часто встречается. Недействительность Iterator делает его немного громоздким в некоторых случаях использования, поэтому они не взаимозаменяемы с точки зрения правильности программы.