sort quick geeksforgeeks description creator big c++ multithreading performance recursion mergesort

quick - problemas de rendimiento en paralelo mergesort C++



quick sort in c (2)

Intenté escribir una implementación paralela de mergesort utilizando hilos y plantillas. El código relevante se enumera a continuación.

He comparado el rendimiento con el tipo de C ++ STL. Mi código es 6 veces más lento que std :: sort cuando no se generan hilos. Jugando con la variable maxthreads (y / o FACTOR) solo pude duplicar el rendimiento, de modo que en el mejor de los casos soy 3 veces más lento que std :: sort. Probé el código en una máquina multiprocesador de 16 núcleos.

htop muestra que los núcleos se utilizan como se esperaba, pero ¿por qué la falta de rendimiento y yo no siento el paralelismo en el tiempo de ejecución general?

Hay un error?

Gracias por una respuesta

#define FACTOR 1 static unsigned int maxthreads = FACTOR * std::thread::hardware_concurrency(); unsigned int workers=0; std::mutex g_mutex; template <typename T> std::vector<T>* mergesort_inplace_multithreading( typename std::vector<T>::iterator* listbegin, typename std::vector<T>::iterator *listend, std::vector<T>* listarg) { if (*listbegin == *listend) { return listarg; } else if (*listend == *listbegin + 1) { return listarg; } else { size_t offset = std::distance(*listbegin, *listend)/2; typename std::vector<T>::iterator listhalf = *listbegin + offset; g_mutex.lock(); if (::workers <= maxthreads-2 and maxthreads >=2) { workers += 2; g_mutex.unlock(); std::thread first_thread(mergesort_inplace_multithreading<T>, listbegin, &listhalf, listarg); std::thread second_thread(mergesort_inplace_multithreading<T>, &listhalf, listend, listarg); first_thread.join(); second_thread.join(); g_mutex.lock(); workers -= 2; g_mutex.unlock(); } else { g_mutex.unlock(); mergesort_inplace_multithreading<T>(listbegin, &listhalf, listarg); mergesort_inplace_multithreading<T>(&listhalf, listend, listarg); } typename std::vector<T> result; typename std::vector<T>::iterator lo_sorted_it = *listbegin; typename std::vector<T>::iterator hi_sorted_it = listhalf; typename std::vector<T>::iterator lo_sortedend = listhalf; typename std::vector<T>::iterator hi_sortedend = *listend; while (lo_sorted_it != lo_sortedend and hi_sorted_it != hi_sortedend) { if (*lo_sorted_it <= *hi_sorted_it) { result.push_back(*lo_sorted_it); ++lo_sorted_it; } else { result.push_back(*hi_sorted_it); ++hi_sorted_it; } }//end while if (lo_sorted_it != lo_sortedend) { //assert(hi_sorted_it == hi_sortedend); result.insert(result.end(), lo_sorted_it, lo_sortedend); } else { //assert(lo_sorted_it == lo_sortedend); result.insert(result.end(), hi_sorted_it, hi_sortedend); } std::copy(result.begin(), result.end(), *listbegin); return listarg; } } int main() { //some tests }


Gracias por la respuesta.

El mutex solo protege a los trabajadores int sin firmar (una variable global) que realiza un seguimiento de cuántos subprocesos se generaron. Si se alcanza un máximo (dado por maxthreads), entonces no se generan más hilos. Lo logra usando el parámetro N en mergesort_mt2.

¿Cuántos núcleos tenía tu máquina?

Aún así, la actuación solo parece duplicarse ...


No necesita un mutex para el mergesort paralelo. Y ciertamente no es necesario iniciar dos subprocesos para cada división de particiones. Lanzas un hilo; la segunda partición se maneja en el hilo actual ; un mejor uso de los recursos de hilo que un hilo sentado sin hacer nada más que esperar a que otros dos terminen.

Primero, el programa de prueba simple, clasificando 20 millones de enteros sin signo. Nota: Todos los programas compilados con Apple LLVM versión 5.1 (clang-503.0.40) (basado en LLVM 3.4svn), 64 bits, subprocesos posix y optimizaciones establecidas en O2

Programa de prueba

int main() { using namespace std::chrono; std::random_device rd; std::mt19937 rng(rd()); std::uniform_int_distribution<unsigned int> dist(0, std::numeric_limits<unsigned int>::max()); std::vector<unsigned int> v, back(20*1000000); for (int i=0; i<5; ++i) { std::cout << "Generating.../n"; std::generate_n(back.begin(), back.size(), [&](){return dist(rng);}); time_point<system_clock> t0, t1; v = back; std::cout << "std::sort: "; t0 = system_clock::now(); std::sort(v.begin(), v.end()); t1 = system_clock::now(); std::cout << duration_cast<milliseconds>(t1-t0).count() << "ms/n"; v = back; std::cout << "mergesort_mt1: "; t0 = system_clock::now(); mergesort_mt1(v.begin(), v.end()); t1 = system_clock::now(); std::cout << duration_cast<milliseconds>(t1-t0).count() << "ms/n"; } return 0; }

Paralelo Mergesort

Comenzamos con algo súper básico. Limitamos el número de subprocesos simultáneos para que sea la concurrencia de hardware informada de la biblioteca estándar. Una vez que alcanzamos ese límite, dejamos de emitir nuevos hilos y simplemente recursemos en los existentes. Este algoritmo trivial tiene un comportamiento sorprendentemente decente una vez distribuido a través de subprocesos soportados por hardware.

template<typename Iter> void mergesort_mt1(Iter begin, Iter end, unsigned int N = std::thread::hardware_concurrency()/2) { auto len = std::distance(begin, end); if (len < 2) return; Iter mid = std::next(begin, len/2); if (N > 1) { auto fn = std::async(mergesort_mt1<Iter>, begin, mid, N-2); mergesort_mt1(mid, end, N-2); fn.wait(); } else { mergesort_mt1(begin, mid, 0); mergesort_mt1(mid, end, 0); } std::inplace_merge(begin, mid, end); }

Salida

Generating... std::sort: 1902ms mergesort_mt1: 1609ms Generating... std::sort: 1894ms mergesort_mt1: 1584ms Generating... std::sort: 1881ms mergesort_mt1: 1589ms Generating... std::sort: 1840ms mergesort_mt1: 1580ms Generating... std::sort: 1841ms mergesort_mt1: 1631ms

Esto parece prometedor, pero ciertamente se puede mejorar.

Paralelo Merge + Biblioteca estándar Ordenar

El algoritmo std::sort varía ampliamente de un proveedor a otro en su implementación. La principal restricción impuesta por el estándar es que debe tener una complejidad promedio O (NlogN). Para lograr esto por el lado del rendimiento, muchos algoritmos std::sort son algunos de los códigos más complejos y optimizados que encontrará en la biblioteca estándar. He leído algunas implementaciones que tienen varias características de clasificación internas. Una de esas implementaciones que he visto usa introsort ( quicksort hasta que recursividad-profundidad es limitada, luego heapsort ) para particiones más grandes, y una vez que se alcanzan particiones pequeñas, sucumbe a una gigantesca distribución de inserción de 16 ranuras, desenrollada a mano.

El punto es que los autores de la biblioteca estándar entienden que un algoritmo de clasificación universal simplemente no se ajusta a todos. A menudo se emplean varios para la tarea, a menudo trabajando juntos en armonía. No ingenuamente pienses que puedes vencerlos; más bien, únete a ellos explotando su arduo trabajo.

Modificar nuestro código es sencillo. Usamos std::sort para todas las particiones menores a 1025. El resto es idéntico:

template<typename Iter> void mergesort_mt2(Iter begin, Iter end, unsigned int N = std::thread::hardware_concurrency()) { auto len = std::distance(begin, end); if (len <= 1024) { std::sort(begin,end); return; } Iter mid = std::next(begin, len/2); if (N > 1) { auto fn = std::async(mergesort_mt2<Iter>, begin, mid, N-2); mergesort_mt2(mid, end, N-2); fn.wait(); } else { mergesort_mt2(begin, mid, 0); mergesort_mt2(mid, end, 0); } std::inplace_merge(begin, mid, end); }

Después de agregar nuestro nuevo caso de prueba al programa de prueba, obtenemos:

Salida

Generating... std::sort: 1930ms mergesort_mt1: 1695ms mergesort_mt2: 998ms Generating... std::sort: 1854ms mergesort_mt1: 1573ms mergesort_mt2: 1030ms Generating... std::sort: 1867ms mergesort_mt1: 1584ms mergesort_mt2: 1005ms Generating... std::sort: 1862ms mergesort_mt1: 1589ms mergesort_mt2: 1001ms Generating... std::sort: 1847ms mergesort_mt1: 1578ms mergesort_mt2: 1009ms

DE ACUERDO. Ahora estamos viendo cosas impresionantes. ¿Pero podemos exprimir aún más?

Combinación paralela + Clasificación estándar con recursión limitada

Si lo piensas, para explotar por completo todo el trabajo duro que se le dio a std::sort , podemos simplemente dejar de recurrir una vez que lleguemos a la población total de subprocesos. Si eso sucede, solo ordena todo lo que tenemos con std::sort y fusiona las cosas juntas cuando hayas terminado. Aunque sea difícil de creer, esto reducirá la complejidad del código. Nuestro algoritmo se convierte en una simple división de particiones en los núcleos, cada uno manejado por std::sort cuando llega el momento:

template<typename Iter> void mergesort_mt3(Iter begin, Iter end, unsigned int N = std::thread::hardware_concurrency()/2) { auto len = std::distance(begin, end); if (len <= 1024 || N < 2) { std::sort(begin,end); return; } Iter mid = std::next(begin, len/2); auto fn = std::async(mergesort_mt3<Iter>, begin, mid, N-2); mergesort_mt3(mid, end, N-2); fn.wait(); std::inplace_merge(begin, mid, end); }

Una vez más, después de agregar esto a nuestro ciclo de prueba ...

Salida

Generating... std::sort: 1911ms mergesort_mt1: 1656ms mergesort_mt2: 1006ms mergesort_mt3: 802ms Generating... std::sort: 1854ms mergesort_mt1: 1588ms mergesort_mt2: 1008ms mergesort_mt3: 806ms Generating... std::sort: 1836ms mergesort_mt1: 1580ms mergesort_mt2: 1017ms mergesort_mt3: 806ms Generating... std::sort: 1843ms mergesort_mt1: 1583ms mergesort_mt2: 1006ms mergesort_mt3: 853ms Generating... std::sort: 1855ms mergesort_mt1: 1589ms mergesort_mt2: 1012ms mergesort_mt3: 798ms

Tal como está escrito, para cualquier partición que tenga 1024 elementos o menos, simplemente delegaremos a std::sort . Si las particiones son más grandes , presentamos un nuevo hilo para manejar un lado de la partición dividida, usando el hilo actual para manejar el otro. Una vez que saturamos el límite de hilo N, dejamos de dividir y simplemente delegamos todo a std::sort lo que std::sort . En resumen, somos un front-end de distribución de múltiples hilos para std::sort .

Resumen

Todavía hay más balas en la cámara que podríamos disparar (usando algo de meta-programación y suponiendo un número de conjunto de concurrencia fijo), pero eso te lo dejo a ti.

Puede aumentar drásticamente su rendimiento de clasificación si simplemente se centra en la creación de particiones, distribuye los hilos hasta que se toquen, utilice un algoritmo de clasificación altamente optimizado para las particiones del piso, luego cosa los elementos para terminar el trabajo. ¿Todavía hay espacio para mejorar? Ciertamente. Pero en su forma más simple presentada arriba no hay bloqueo, mutex, etc. La diferencia entre la muestra final y bare std::sort es una enorme mejora del 58% en conjuntos de datos idénticos en un pequeño MacBook Air a mediados de 2011 con 4 gb RAM y un procesador duo-core i7. Esto es impresionante, y teniendo en cuenta el poco código que se necesitó para hacerlo, simplemente-claro f''ing impresionante .