Страницы

Поиск по вопросам

воскресенье, 24 ноября 2019 г.

Словарь на C++ как (Dictionary) на C#


На C# имеется удивительно быстрый словарь (Dictionary), хотелось бы узнать, а имеетс
ли такой же производительный только на C++ ? Пробовал unordered_map, hash_map, map, но производительность в разы ниже чем у Dictionary сишарповского...
P.S: Пара имеет вид 
    


Ответы

Ответ 1



На самом деле, сравнение языков -- штука неблагодарная. Всегда найдутся тесты, н которых один из языков выиграет по сравнению с другим, и всегда найдутся люди, считающие, что данный тест не релевантен и подобный код никогда не будет встречаться в реальности. Тем не менее, я бы не сказал, что результаты ТС очень уж неожиданны: в .NET действительн выделение памяти обычно происходит быстрее, чем в нативных языках без кастомного аллокатора. А небольшие тесты обычно гораздо больше нагружают аллокатор чем, скажем, механизмы вызова функций. Причиной такой разницы в производительности аллокатора является то, что объекты C+ невозможно перемещать в памяти, а значит, привычный алгоритм выделения памяти (который как известно, поддерживает список свободных блоков, и ищет подходящий при аллокации работает довольно медленно, и, хуже того, требует глобальной блокировки памяти (что ещё более ухудшает ситуацию в многопоточном сценарии). Кроме того, объекты в C++ имеют тенденцию освобождаться быстро как только можно, что приводит к дополнительной нагрузке на освобождение памяти, которое тоже требует глобальную блокировку. В среде .NET же всё происходит по-другому. Объекты всегда выделяются на вершине heap-памяти а значит, выделение не медленнее, чем InterlockedIncrement. .NET'у не нужно поддерживать список свободных блоков потому, что при сборке мусора происходит компактификация heap-памяти: объекты перемещаются, заполняя "дыры". Кроме того, известия о том, что код на C++ вполне может быть медленнее аналогичног кода на C#, давно не новость. Вот, например, замечательная серия статей о простом приложении от мастеров нативного программирования, и резюме Джефа Этвуда: Чтобы обойти по производительности версию на C#, Реймонду пришлось написать собственны процедуры ввода-вывода, переписать класс string, воспользоваться кастомным аллокатором, а также собственной процедурой отображения кодовых страниц. Это подтверждается и бенчмарком, который приведён ниже: нативные контейнеры "из коробки" существенно проигрывают дотнетовским, (некоторые) самописные контейнеры выигрывают. Теперь самое интересное: измерения. C#: using System; using System.Collections.Generic; using System.Diagnostics; namespace Sharp { class Program { static void Main(string[] args) { var dict = new Dictionary(); int seed = 1; var timer = new Stopwatch(); timer.Start(); for (int i = 0; i < 10000000; i++) { seed = 1664525 * seed + 1013904223; dict.Add(seed, i); } timer.Stop(); Console.WriteLine( "elapsed time = {0} ms, dictionary entries count = {1}", timer.ElapsedMilliseconds, dict.Count); } } } C++: #include "stdafx.h" #include #include #include using namespace std; int main(int argc, char* argv[]) { map dict; int seed = 1; auto begin = clock(); for (int i = 0; i < 10000000; i++) { seed = 1664525 * seed + 1013904223; dict.insert(make_pair(seed, i)); } auto end = clock(); double elapsedMs = double(end - begin) * 1000.0 / CLOCKS_PER_SEC; cout << "elapsed time = " << elapsedMs << " ms, dictionary entries count = " << dict.size() << endl; return 0; } Результаты измерений (release mode, 5 запусков подряд без отладчика): C# elapsed time = 1138 ms, dictionary entries count = 10000000 elapsed time = 1127 ms, dictionary entries count = 10000000 elapsed time = 1133 ms, dictionary entries count = 10000000 elapsed time = 1134 ms, dictionary entries count = 10000000 elapsed time = 1129 ms, dictionary entries count = 10000000 C++ elapsed time = 8377 ms, dictionary entries count = 10000000 elapsed time = 8408 ms, dictionary entries count = 10000000 elapsed time = 8377 ms, dictionary entries count = 10000000 elapsed time = 8377 ms, dictionary entries count = 10000000 elapsed time = 8361 ms, dictionary entries count = 10000000 Среднее время: C# = 1132 мс, C++ = 8379 мс. Я не утверждаю, что мои тесты идеальны. Кроме того, они релевантны лишь на моём компьютере Если кто-то предложит лучшую методику измерения, я с удовольствием применю её тоже. Тем не менее, в моих условиях производительность System.Collections.Generic.Dictionary на добавление элементов существенно превосходит производительность std::map. Обратите внимание, что Dictionary использует хэш-таблицы, в то время как std::ma в моей имплементации использует красно-чёрное дерево в качестве несущей структуры данных. Хэш-таблицы обычно сами по себе быстрее, так что скорость аллокации -- не единственная причина лучшей скорости у Dictionary. Замена в C++ make_pair(seed, i) на pair(seed, i) по совету @igumnov н привела к большому отличию: 8361/8392/8361/8408/8361/8345. Замена в C++ std::map на std::unordered_map по совету @Котик привела к значительном ускорению: 2230/2230/2230/2230/2246 (среднее 2233). Тем не менее, C++ всё ещё почти вдвое медленнее. Заменил в C++ std::unordered_map на uthash по совету @igumnov. Результат немног хуже, чем std::unordered_map: 2963/2932/2948/2948/2932. Код: void testUhash() { struct myint { int key; int value; UT_hash_handle hh; }; struct myint* dict = NULL; int seed = 1; auto begin = clock(); for (int i = 0; i < 10000000; i++) { seed = 1664525 * seed + 1013904223; struct myint* ps = (struct myint*)malloc(sizeof(struct myint)); ps->key = seed; ps->value = i; HASH_ADD_INT(dict, key, ps); } auto end = clock(); double elapsedMs = double(end - begin) * 1000.0 / CLOCKS_PER_SEC; cout << "elapsed time = " << elapsedMs << " ms, dictionary entries count = " << HASH_COUNT(dict) << endl; } Добавил capacity = 10000000 в C++ и для честного сравнения в C# тоже. Изменения: C++: unordered_map dict(10000000); C#: var dict = new Dictionary(10000000); Действительно, стало скорее: C++: 1826/1856/1857/1841/1825, среднее 1841 C#: 790/786/801/790/791, среднее 792 По-прежнему C# более чем вдвое впереди. По совету @KoVadim убрал вычисление seed (capacity оставил), теперь рабочий цикл таков: C++: for (int i = 0; i < 10000000; i++) { //seed = 1664525 * seed + 1013904223; dict.insert(pair(i, i)); } C#: for (int i = 0; i < 10000000; i++) { //seed = 1664525 * seed + 1013904223; dict.Add(i, i); } Результаты: C++: 1498/1514/1498/1498/1498, среднее 1501 C#: 129/129/135/133/132, среднее 132 По совету @igumnov добавил в бенчмарк khash. Код: KHASH_MAP_INIT_INT(32, int) void testKhash() { int seed = 1; khiter_t iter; khash_t(32)* dict = kh_init(32); int dummy; auto begin = clock(); for (int i = 0; i < 10000000; i++) { seed = 1664525 * seed + 1013904223; iter = kh_put(32, dict, seed, &dummy); kh_value(dict, iter) = i; } auto end = clock(); double elapsedMs = double(end - begin) * 1000.0 / CLOCKS_PER_SEC; cout << "elapsed time = " << elapsedMs << " ms, dictionary entries count = " << kh_size(dict) << endl; } Результат: 577/577/608/577/577, среднее 583, массивный вин для нативного кода. Напомню, что лучший результат стандартного .NET-контейнера -- 792 мс. Кто предложит кастомный контейнер под .NET? Попробовал имплементацию для .NET FastDictionary (проект MapReduce.NET). Получилось немного медленнее, чем встроенный Dictionary: 853/865/842/841/842, среднее 849. Попробовал скорость чистой аллокации для проверки гипотезы @Dith: 10 миллионов раз запускается конструктор пустого класса. Код: C#: static class Allocation { class Foo { } static public void Test() { const int size = 10000000; var timer = new Stopwatch(); timer.Start(); for (int i = 0; i < size; i++) { new Foo(); } timer.Stop(); Console.WriteLine("elapsed time = {0} ms", timer.ElapsedMilliseconds); } } C++: void testAlloc() { const int size = 10000000; LARGE_INTEGER li; if (!QueryPerformanceFrequency(&li)) exit(1); double freq = double(li.QuadPart)/1000.0; QueryPerformanceCounter(&li); auto begin = li.QuadPart; for (int i = 0; i < size; i++) new Foo(); QueryPerformanceCounter(&li); auto end = li.QuadPart; double elapsedMs = double(end - begin) / freq; cout << "elapsed time = " << elapsedMs << " ms" << endl; } Результаты: C#: 58/54/28/55/55 (среднее 50) C++: 407.719/400.693/401.674/401.926/399.976 (среднее 402.4)

Ответ 2



Продолжение весьма интересной дискуссии: как сказал @VladD, дотнетовский Dictionary основан на HashMap, так вот Самая неудачная реализация функции GetHashCode в .NET Framework – это реализация, используемая по умолчанию в структурах. Дело в том, что эта функция для структур делает следующее. Она с помощью рефлексии перебирает все поля и пытается получить хэш-код. Найдя первое поле, от которого можно получить хэш-код, функция завершает свою работу, возвращая это значение. В большинстве случаев она возвращает хэш-код первого попавшегося поля. Однако если структура состоит из ссылочных типов, и все они установлены в null, то функция по аналогии с классом возвращает порядковый номер объекта. .... У такого подхода есть два недостатка. Первое – такой хэш-код может оказаться и часто оказывается некорректным, так как по одному полю тяжело идентифицировать всю структуру. Во-вторых, из-за использования рефлексии этот способ далек от идеальной производительности. Поэтому при необходимости получения хэш-значений от структур, лучше реализовать соответствующие функции самостоятельно. источник @igumnov По поводу замеров памяти, есть хорошая статья "Обработка больших объемов данных в памяти на C#"

Ответ 3



Во-первых, вспомним, что у map первый параметр - тип ключа, второй - тип значения. Вы в каждой записи добавляете ключ seed = 1664525 * seed + 1013904223; где seed = 1 постоянно. Соответственно, передается отдно и то-же значение ключа, что является наихудшим значением для вставки. Во-вторых, и самое главное. map эффективен для поиска значения по ключу и очень неэффективен при добавлении-удаления элементов. Ваш же тест делает только добавление. Основные затраты при таком тесте - не аллокация, а добавление в дереве (красно-черном, как вы правильно заметили). Если вы хотите сравнить dictionnary в дотнете и map в stl, вам необходимо написат тест, в котором сначала заполняются значения, а потом измерять время доступа к случайным значениям в цикле. Если вам в приложении нужно часто искать значения - используем map, но тогда и тес пишем другой. Если вам нужно часто и много добавлять значения - используем vector, если добавлять/удалять - list. Тогда и тест (для добавления) будет другой: vector vec; for (int i = 0; i < 1000000; i++) { vec.push_back(i); } Ваш код для 1000000 элементов выполняется у меня 8386 ms, пример с вектором - 251 мс., в 33 раза быстрее.

Ответ 4



Напишу здесь, так как мой лимит коментариев кончился Попробовал скорость чистой аллокации для проверки гипотезы @Dith: 10 миллионов раз запускается конструктор пустого класса. Этот тест показывает незнание работы компилятора и среды исполнения. В С++ кода буде утечка, в шарповом - нет, так как GC все подчистит. Это уже первый звоночек, что тесты не равнозначные. Добавили хотя бы вызов деструктора. (Но тесты показывают, что это не сильно ускорит процесс). НО! в шарповом коде по факту количество выделенной памяти не будет увеличиватьс - объект будет уничтожаться, а новый помещаться в старую память. Так как объект пустой, инициализация будет быстрая. Плюс память уже выделена. Формально - присваивание пары указателей + memset(). Но jit оптимизатор может увидеть, что объект хоть и создается, но не используется а конструктор пустой и выбросить. И по факту - гонять пустой цикл. Хотя в идеале может потом и его выбросить. Но это нужно смотреть отдельно. Я не настолько силен в C#, но думаю, что если начать выводить адрес объекта, которы создается, то он будет постоянно одним и тем же (либо там будет десяток различных адресов, которые в цикле перебираются). То есть, по факту, с++ код выглядел где то так (очень сильно упрощенный код, на коленке): Foo * f; // это глобальная переменная. for (int i = 0; i < size; i++) { // это как бы конструктор Foo * t; if (f == NULL) // если нет объекта, создадим t = alloc_memery(); init(t); else { t = f; init(t); // а это часть конструктора. Память то уже выделена, нам только поля поправить. f = NULL; // объект мы забрали } // а это деструктор. f = t; // вернули объект назад. t = NULL; } Получился такой себе пул объектов на один объект. Но можно сделать лучше, что как раз и сделает тесты более правильными. char * t = new char[1000]; // выделим себе немножко памяти. for (int i = 0; i < 10000000; i++) { new (t) Foo(); // видите странный оператор new?:) } delete[] t; (оператор new, который в коде - это такая специальная форма, называется placement new). Самое интересное - теперь утечки нет, и код работает в раз 10 быстрее оригинала Я считаю этот код максимально правдоподобным аналогом шарпового кода. Но с другой стороны, можно написать ещё красивее for (int i = 0; i<10000000; i++) Foo f(); // да, здесь будет создаваться объект и вызываться конструктор-деструктор не удивлюсь, если шарповый оптимизатор также сделал в оригинальном коде выделени памяти на стеке (это возможно, я точно знаю, что 7 java так умеет). А выделение память на стеке очень дешевое в плане скорости. Кто то скажет, во в C# прям взял написал и оно быстро, а в С++ нужно ещё что то добавлять Но в С++ "хаки" делаются легко и не принужденно, а в C# можно быстро напороться на препятстви (да, я знаю об Unsafe коде). Но приведенный выше код я хаком не считаю. И я скорее всего не буду создавать с помощью new миллионы объектов в цикле. А если и понадобиться, то сделаю преаллокацию на нужное кол-во объектов. И это будет быстрее.

Комментариев нет:

Отправить комментарий