Реализация gcc std::unordered_map медленная? Если да - то почему?

Мы разрабатываем высокопроизводительное критическое программное обеспечение на C++. Там нам нужна параллельная хеш-карта и реализованная. Поэтому мы написали бенчмарк, чтобы выяснить, насколько медленнее наша параллельная хеш-карта по сравнению с std::unordered_map.

Но std::unordered_map кажется невероятно медленным... Итак, это наш микротест (для параллельной карты мы создали новый поток, чтобы убедиться, что блокировка не будет оптимизирована, и обратите внимание, что я никогда не вставляю 0, потому что я также тестирую с google::dense_hash_map, для которого требуется нулевое значение):

boost::random::mt19937 rng;
boost::random::uniform_int_distribution<> dist(std::numeric_limits<uint64_t>::min(), std::numeric_limits<uint64_t>::max());
std::vector<uint64_t> vec(SIZE);
for (int i = 0; i < SIZE; ++i) {
    uint64_t val = 0;
    while (val == 0) {
        val = dist(rng);
    }
    vec[i] = val;
}
std::unordered_map<int, long double> map;
auto begin = std::chrono::high_resolution_clock::now();
for (int i = 0; i < SIZE; ++i) {
    map[vec[i]] = 0.0;
}
auto end = std::chrono::high_resolution_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - begin);
std::cout << "inserts: " << elapsed.count() << std::endl;
std::random_shuffle(vec.begin(), vec.end());
begin = std::chrono::high_resolution_clock::now();
long double val;
for (int i = 0; i < SIZE; ++i) {
    val = map[vec[i]];
}
end = std::chrono::high_resolution_clock::now();
elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - begin);
std::cout << "get: " << elapsed.count() << std::endl;

(EDIT: весь исходный код можно найти здесь: http://pastebin.com/vPqf7eya)

Результат для std::unordered_map:

inserts: 35126
get    : 2959

Для google::dense_map:

inserts: 3653
get    : 816

Для нашей параллельной карты с ручной поддержкой (которая выполняет блокировку, хотя тест является однопоточным, но в отдельном потоке порождения):

inserts: 5213
get    : 2594

Если я скомпилирую тестовую программу без поддержки pthread и запущу все в основном потоке, я получу следующие результаты для нашей параллельной карты, поддерживаемой вручную:

inserts: 4441
get    : 1180

Я компилирую с помощью следующей команды:

g++-4.7 -O3 -DNDEBUG -I/tmp/benchmap/sparsehash-2.0.2/src/ -std=c++11 -pthread main.cc

Так что особенно вставки на std::unordered_map кажутся крайне дорогими - 35 секунд против 3-5 секунд на других картах. Кроме того, время поиска кажется довольно высоким.

Мой вопрос: почему это? Я прочитал еще один вопрос о stackoverflow, где кто-то спрашивает, почему std::tr1::unordered_map медленнее, чем его собственная реализация. Там ответ с наивысшим рейтингом гласит, что std::tr1::unordered_map необходимо реализовать более сложный интерфейс. Но я не вижу этого аргумента: мы используем подход ведра в нашей concurrent_map, std::unordered_map тоже использует подход ведра (google::dense_hash_map не использует, но чем std::unordered_map должен быть хотя бы таким же быстрым, как наша защищенная от параллелизма версия?). Кроме того, я не вижу в интерфейсе ничего, что заставляло бы функцию, из-за которой хэш-карта работала плохо...

Итак, мой вопрос: правда ли, что std::unordered_map кажется очень медленным? Если нет: что не так? Если да: в чем причина этого.

И мой главный вопрос: почему вставка значения в std::unordered_map так ужасно затратна (даже если мы зарезервируем достаточно места в начале, это не будет работать намного лучше, так что перефразирование, похоже, не проблема)?

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

Прежде всего: да, представленный тест не безупречен - это потому, что мы много с ним играли, и это просто хак (например, распределение uint64 для генерации целых чисел на практике было бы не очень хорошей идеей, исключите 0 в петля какая-то глупая и т. д.).

На данный момент большинство комментариев объясняют, что я могу сделать unordered_map быстрее, предварительно выделив для него достаточно места. В нашем приложении это просто невозможно: мы разрабатываем систему управления базой данных и нуждаемся в хеш-карте для хранения некоторых данных во время транзакции (например, информации о блокировке). Таким образом, эта карта может быть любой: от 1 (пользователь просто делает одну вставку и фиксирует) до миллиардов записей (если происходит полное сканирование таблицы). Здесь просто невозможно предварительно выделить достаточно места (и просто выделить много в начале будет потреблять слишком много памяти).

Кроме того, я извиняюсь, что не сформулировал свой вопрос достаточно ясно: я не очень заинтересован в том, чтобы сделать unordered_map быстрым (использование плотной хеш-карты Google отлично работает для нас), я просто не очень понимаю, откуда берутся эти огромные различия в производительности. . Это не может быть просто предварительное выделение (даже при наличии достаточного количества предварительно выделенной памяти, плотная карта на порядок быстрее, чем unordered_map, наша параллельная карта, поддерживаемая вручную, начинается с массива размером 64, то есть меньше, чем unordered_map).

Так в чем же причина такой плохой работы std::unordered_map? Или по-другому спросили: можно ли написать реализацию интерфейса std::unordered_map, которая соответствует стандарту и (почти) так же быстро, как плотная хеш-карта Google? Или в стандарте есть что-то, что заставляет разработчика выбирать неэффективный способ его реализации?

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

Профилируя, я вижу, что много времени уходит на целочисленные деления. std::unordered_map использует простые числа для размера массива, в то время как другие реализации используют степень двойки. Почему std::unordered_map использует простые числа? Чтобы работать лучше, если хэш плохой? Для хороших хэшей это не имеет значения.

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

Это номера для std::map:

inserts: 16462
get    : 16978

Тааак: почему вставки в std::map быстрее, чем вставки в std::unordered_map... Я имею в виду WAT? std::map имеет худшую локальность (дерево по сравнению с массивом), требует больше аллокаций (на вставку по сравнению с повторным хешированием + плюс ~1 на каждое столкновение) и, самое главное, имеет другую алгоритмическую сложность (O(logn) против O(1)) !


person Markus Pilman    schedule 23.07.2012    source источник
comment
Большинство контейнеров в std ОЧЕНЬ консервативны в своих оценках, я бы посмотрел на используемое вами количество сегментов (указанное в конструкторе) и увеличил его до лучшей оценки для вашего SIZE.   -  person Ylisar    schedule 23.07.2012
comment
Вы пробовали concurrent_hash_map из Intel TBB? threadingbuildingblocks.org/docs/help/reference/   -  person MadScientist    schedule 23.07.2012
comment
1. Что такое РАЗМЕР? 2. Почему вы указываете диапазон в терминах uint64_t для uniform_int_distribution int? Это приводит к распределению от 0 до -1. Что это делает? 3. Если вам не нужен 0, почему бы вам просто не указать это в своем дистрибутиве?   -  person Howard Hinnant    schedule 23.07.2012
comment
@MadScientist Мы рассмотрели TBB. Проблема заключается в лицензировании: это исследовательский проект, и мы еще не уверены, как мы его опубликуем (скорее всего, с открытым исходным кодом, но если мы хотим разрешить использование в коммерческом продукте, GPLv2 слишком ограничительна). Также это другая зависимость. Но, может быть, мы будем использовать его в более поздний момент времени, пока мы можем жить без него.   -  person Markus Pilman    schedule 24.07.2012
comment
Запуск его под профилировщиком, например. valgrind, может быть проницательным.   -  person Maxim Egorushkin    schedule 24.07.2012
comment
Было бы полезно, если бы вы разместили полный исходный код, чтобы мы могли его скомпилировать и посмотреть, что вы видите.   -  person Maxim Egorushkin    schedule 24.07.2012
comment
@MaximYegorushkin Отредактировал сообщение и добавил ссылку pastebin на полный источник. Мой профилировщик говорит мне, что для целочисленного деления уходит много времени на вычисления. Другие карты, которые мы тестировали, использовали степени двойки, в то время как std::unordered_map использует простые числа... Это может быть причиной. Почему они используют простые числа?   -  person Markus Pilman    schedule 24.07.2012
comment
Локальность в хеш-таблице в лучшем случае немного лучше, чем локальность в дереве, по крайней мере, если хэш-функция случайна. Эта хеш-функция гарантирует, что вы редко будете получать доступ к соседним объектам в ближайшее время. Единственное преимущество, которое у вас есть, заключается в том, что массив хеш-таблиц представляет собой один непрерывный блок. В любом случае это может быть верно для дерева, если куча не фрагментирована и вы строите дерево сразу. Как только размер превысит размер кеша, различия в местонахождении почти не будут влиять на производительность.   -  person Steve314    schedule 25.07.2012
comment
Взгляните на выровненное хеширование в ulib, это невероятно быстро и просто в использовании. Насколько мне известно, текущий ствол также реализовал параллельную версию hashmap.   -  person    schedule 25.09.2012


Ответы (3)


Я нашел причину: это проблема gcc-4.7!!

С gcc-4.7

inserts: 37728
get    : 2985

С gcc-4.6

inserts: 2531
get    : 1565

Таким образом, std::unordered_map в gcc-4.7 не работает (или моя установка, которая представляет собой установку gcc-4.7.0 на Ubuntu - и другую установку, которая представляет собой gcc 4.7.1 при тестировании Debian).

Я отправлю отчет об ошибке... до тех пор: НЕ используйте std::unordered_map с gcc 4.7!

person Markus Pilman    schedule 24.07.2012
comment
Есть ли что-нибудь в дельте от 4.6, что могло бы вызвать это? - person Mark Canlas; 24.07.2012
comment
В списке рассылки уже есть сообщение. Обсуждение, кажется, указывает на исправления в обработке max_load_factor, которые привели к разнице в производительности. - person jxh; 24.07.2012
comment
Плохое время для этой ошибки! У меня была очень низкая производительность с unordered_map, но я рад, что об этом сообщили и исправили. - person Bo Lu; 02.08.2012
comment
+1 - Какой отстой BBBBBUG .. Интересно, что происходит с gcc-4.8.2 - person ikh; 25.03.2014
comment
Есть обновления по этой ошибке? Существует ли он для более поздних версий GCC (5+)? - person rph; 13.10.2016
comment
@rkioji Одним из изменений в GCC 7 является новая две политики повторного хеширования для использования с внутренними компонентами _Hashtable, поэтому производительность, вероятно, теперь другая. - person Jeffrey Bosboom; 24.05.2017

Я предполагаю, что вы неправильно выбрали размер unordered_map, как предложил Илисар. Когда цепочки становятся слишком длинными в unordered_map, реализация g++ автоматически перехеширует хэш-таблицу большего размера, и это сильно снижает производительность. Если я правильно помню, unordered_map по умолчанию равно (наименьшее простое число больше) 100.

В моей системе не было chrono, поэтому я замерил время с times().

template <typename TEST>
void time_test (TEST t, const char *m) {
    struct tms start;
    struct tms finish;
    long ticks_per_second;

    times(&start);
    t();
    times(&finish);
    ticks_per_second = sysconf(_SC_CLK_TCK);
    std::cout << "elapsed: "
              << ((finish.tms_utime - start.tms_utime
                   + finish.tms_stime - start.tms_stime)
                  / (1.0 * ticks_per_second))
              << " " << m << std::endl;
}

Я использовал SIZE из 10000000, и мне пришлось немного изменить для моей версии boost. Также обратите внимание, что я заранее задал размер хеш-таблицы, чтобы она соответствовала SIZE/DEPTH, где DEPTH — это оценка длины цепочки сегментов из-за хеш-коллизий.

Редактировать: Говард указывает мне в комментариях, что максимальный коэффициент загрузки для unordered_map равен 1. Таким образом, DEPTH определяет, сколько раз код будет перефразироваться.

#define SIZE 10000000
#define DEPTH 3
std::vector<uint64_t> vec(SIZE);
boost::mt19937 rng;
boost::uniform_int<uint64_t> dist(std::numeric_limits<uint64_t>::min(),
                                  std::numeric_limits<uint64_t>::max());
std::unordered_map<int, long double> map(SIZE/DEPTH);

void
test_insert () {
    for (int i = 0; i < SIZE; ++i) {
        map[vec[i]] = 0.0;
    }
}

void
test_get () {
    long double val;
    for (int i = 0; i < SIZE; ++i) {
        val = map[vec[i]];
    }
}

int main () {
    for (int i = 0; i < SIZE; ++i) {
        uint64_t val = 0;
        while (val == 0) {
            val = dist(rng);
        }
        vec[i] = val;
    }
    time_test(test_insert, "inserts");
    std::random_shuffle(vec.begin(), vec.end());
    time_test(test_insert, "get");
}

Изменить:

Я изменил код, чтобы было проще заменить DEPTH.

#ifndef DEPTH
#define DEPTH 10000000
#endif

Итак, по умолчанию выбирается наихудший размер хеш-таблицы.

elapsed: 7.12 inserts, elapsed: 2.32 get, -DDEPTH=10000000
elapsed: 6.99 inserts, elapsed: 2.58 get, -DDEPTH=1000000
elapsed: 8.94 inserts, elapsed: 2.18 get, -DDEPTH=100000
elapsed: 5.23 inserts, elapsed: 2.41 get, -DDEPTH=10000
elapsed: 5.35 inserts, elapsed: 2.55 get, -DDEPTH=1000
elapsed: 6.29 inserts, elapsed: 2.05 get, -DDEPTH=100
elapsed: 6.76 inserts, elapsed: 2.03 get, -DDEPTH=10
elapsed: 2.86 inserts, elapsed: 2.29 get, -DDEPTH=1

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

person jxh    schedule 23.07.2012
comment
std::unordered_map имеет максимальный коэффициент загрузки по умолчанию, равный 1. Таким образом, за исключением начального количества ковшей, ваша ГЛУБИНА игнорируется. При желании можно map.max_load_factor(DEPTH). - person Howard Hinnant; 23.07.2012
comment
@HowardHinnant: Спасибо за эту информацию. Таким образом, DEPTH игнорируется, но по-прежнему определяет, как часто карта будет перефразироваться в большую карту. Ответ обновлен, и еще раз спасибо - person jxh; 23.07.2012
comment
@user315052 Да, я знаю, что могу сделать его лучше, придав ему разумный размер в начале, но я не могу этого сделать в нашем программном обеспечении (это исследовательский проект - СУБД - и я не могу знать, сколько я вставлю - оно может варьироваться от 0 до 1 миллиарда...). Но даже с предварительной алликацией она медленнее, чем наша карта, и намного медленнее, чем гугл-плотная карта — мне все еще интересно, в чем же заключается большая разница. - person Markus Pilman; 24.07.2012
comment
@MarkusPilman: я не знаю, как мои результаты сравниваются с вашими, потому что вы никогда не указывали, с каким большим SIZE вы работали. Я могу сказать, что unordered_map в два раза быстрее с DEPTH установленным на 1 и правильно распределенным. - person jxh; 24.07.2012
comment
@user315052 user315052 о да, извините, я выбрал РАЗМЕР 1 000 000 - то есть такой же, как у вас. Я также получил ускорение более чем в 2 раза для вставок, но это все еще очень медленно по сравнению с другими реализациями. - person Markus Pilman; 24.07.2012
comment
еще раз извините... я насчитал 10'000'000 - person Markus Pilman; 24.07.2012
comment
@MarkusPilman: Мое время уже в секундах. Я думал, что ваше время в миллисекундах. Если вставки с DEPTH, установленным на 1, занимают менее 3 секунд, как это на порядок медленнее? - person jxh; 24.07.2012
comment
@ user315052 Да, это миллисекунды. Понятия не имею - у нас на разные программы, написанные двумя разными людьми - 10 миллионов вставок занимает ~35 секунд. Разница для получения может заключаться в том, что моя машина довольно старая. А вот для вставок странный... Используем gcc 4.7 (забыл сказать). попробую с gcc-4.6 - person Markus Pilman; 24.07.2012

Я запустил ваш код на 64-разрядном / AMD / 4-ядерном компьютере (2,1 ГГц) и получил следующие результаты:

MinGW-W64 4.9.2:

Использование std::unordered_map:

inserts: 9280 
get: 3302

Использование std::map:

inserts: 23946
get: 24824

VC 2015 со всеми известными мне флагами оптимизации:

Использование std::unordered_map:

inserts: 7289
get: 1908

Использование std::map:

inserts: 19222 
get: 19711

Я не тестировал код с помощью GCC, но думаю, что он может быть сравним с производительностью VC, поэтому, если это так, то GCC 4.9 std::unordered_map все еще не работает.

[ИЗМЕНИТЬ]

Так что да, как кто-то сказал в комментариях, нет причин думать, что производительность GCC 4.9.x будет сравнима с производительностью VC. Когда у меня будет изменение, я буду тестировать код на GCC.

Мой ответ - просто создать какую-то базу знаний для других ответов.

person Christian Leon    schedule 16.11.2015
comment
Я не тестировал код с помощью GCC, но думаю, что он может быть сравним с производительностью VC. Совершенно необоснованное утверждение, без какого-либо сравнительного анализа, сравнимого с тем, который был найден в исходном посте. Этот ответ ни в каком смысле не отвечает на вопрос, не говоря уже об ответе на вопрос «почему». - person 4ae1e1; 17.11.2015
comment
Я не тестировал код с помощью GCC... как вам удалось приобрести и использовать MinGW, зная о нем так мало? MinGW — это, по сути, тщательно отслеживаемый порт GCC. - person underscore_d; 26.06.2016