«Будущее принадлежит параллельным вычислениям». MaksSun

Введение

Золотые времена подошли к концу, когда разработчикам можно было ничего не делать, а программное обеспечение работало с каждым годом все быстрее.

Распараллеливание на уровне данных. Принцип "Разделяй и властвуй".

Алгоритмы последовательных сортировок в прямом виде достаточно сложены для распараллеливания. Поэтому прибегают к стратегии «разделяй и властвуй».

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

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

Процесс параллельного распараллеливания «разделяй и властвуй» включает следующие шаги.

  1. Разделение. Исходная задача разбивается на несколько меньших и независимых подзадач. Это может быть сделано путем разделения данных, декомпозиции пространства поиска или другими методами, в зависимости от конкретной задачи.

  2. Властвование. Каждая подзадача решается независимо и параллельно. Каждая подзадача может быть выполнена на своем собственном процессоре, ядре или узле в вычислительном кластере.

  3. Объединение. Результаты выполнения подзадач объединяются для получения окончательного результата. Этот шаг может включать слияние данных, комбинирование результатов или другие операции, в зависимости от характера задачи.

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

Суть метода "Разделяй и властвуй"

Программная реализация

Этап 1

Самый быстрый алгоритм (который я знаю) сортировки является "Быстрая сортировка" используем его для локальной сортировки.

// Функция для разделения массива на подмассивы с помощью опорного элемента
template <class vec, class typ>
typ partition(vec& arr, typ low, typ high) {
	using VectorType = typename std::remove_reference<decltype(arr)>::type::value_type;
	VectorType pivot = arr[high]; // Выбираем последний элемент в качестве опорного
	typ i = low - 1; // Индекс меньшего элемента

	for (typ j = low; j <= high - 1; ++j) {
		// Если текущий элемент меньше или равен опорному
		if (arr[j] <= pivot) {
			++i; // Увеличиваем индекс меньшего элемента
			std::swap(arr[i], arr[j]);
		}
	}

	std::swap(arr[i + 1], arr[high]);
	return i + 1;
}

// Рекурсивная функция для выполнения быстрой сортировки
template <class vec, class typ>
void quickSort(vec& arr, typ low, typ high) {
	if (low < high) {
		// Индекс опорного элемента после разделения
		typ pivotIndex = partition<vec, typ>(arr, low, high);

		// Рекурсивно сортируем элементы до и после опорного элемента
		quickSort<vec, typ>(arr, low, pivotIndex - 1);
		quickSort<vec, typ>(arr, pivotIndex + 1, high);
	}
}

Этап 2

Для распараллеливания используем потоки (std::thread).

// Функция разделяй и властвуй для быстрой сортировки с использованием потоков
template <class vec, class typ>
void parallelDivideAndConquerMergeSort(vec& array) {
    typ size = array.size();
    typ numThreads = std::thread::hardware_concurrency();
    typ chunkSize = size / numThreads;

    std::vector<std::thread> threads;
    for (typ i = 0; i < numThreads; ++i) {
        typ start = i * chunkSize;
        typ end = (i == numThreads - 1) ? size - 1 : start + chunkSize - 1;
        threads.emplace_back(quickSort<vec, typ>, std::ref(array), start, end);
    }

    for (auto& thread : threads) {
        thread.join();
    }

    typ step = chunkSize;
    while (step < size) {
        for (typ i = 0; i < size - step; i += 2 * step) {
            typ left = i;
            typ mid = i + step - 1;
            typ right = std::min<typ>(i + 2 * step - 1, size - 1);

            merge<vec, typ>(array, left, mid, right);
        }
        step *= 2;
    }
}

Этап 3

Функция main

int main() {
	SetConsoleCP(1251);
	SetConsoleOutputCP(1251);

	for (size_t i = 1; i <= 10; ++i)
	{
		size_t size_vector = i * (size_t)1e7;
		std::vector<int> arr(size_vector);
		std::iota(arr.begin(), arr.end(), 0);
		std::mt19937_64 urng{ 121216 };
		std::shuffle(arr.begin(), arr.end(), urng);

		auto start = std::chrono::steady_clock::now();
		//quickSort(arr, (size_t) 0, size_vector);
		parallelDivideAndConquerMergeSort<std::vector<int>, long long>(arr);
		auto end = std::chrono::steady_clock::now();

		std::cout << "Время выполнения: "
			<< std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
			<< " миллисекунд" << std::endl;

		if (std::is_sorted(arr.begin(), arr.end()))
			std::cout << "Массив отсортирован";
		else
			std::cout << "Не отсортирован массив";
		std::cout << std::endl;

	}

	return 1;
}

Вычислительные эксперименты

Вычисления проводились на вычислительной системе №1 (ВС №1): персональном компьютере с шестиядерным процессором AMD Ryzen 5 PRO 4400 Ггц с Radeon Graphics 3.70 GHz, 32 Гб оперативной памяти, операционная система Windows 10x64, SSD 128 Гб.

Время выполнения последовательного алгоритма Быстрой сортировки.

В таблице 1 показаны результаты последовательной быстрой сортировки (Quick sort) принципом «разделяй и властвуй», полученные на вычислительной системе № 1 (6 ядер) с использованием технологии потоков (std::thread).

Таблица 1.
Таблица 1.

В таблице 2 показаны результаты параллельной быстрой сортировки (Quick sort) принципом «разделяй и властвуй», полученные на вычислительной системе №1 (6 ядер) с использованием технологии потоков (std::thread).

Таблица 2
Таблица 2

Вывод

Ускорение достигало до 5 раз включительно.

Весь код

#include <iostream>
#include <vector>
#include <random>
#include <numeric>
#include <chrono>
#include <thread>
#include <Windows.h>

// Функция для разделения массива на подмассивы с помощью опорного элемента
template <class vec, class typ>
typ partition(vec& arr, typ low, typ high) {
	using VectorType = typename std::remove_reference<decltype(arr)>::type::value_type;
	VectorType pivot = arr[high]; // Выбираем последний элемент в качестве опорного
	typ i = low - 1; // Индекс меньшего элемента

	for (typ j = low; j <= high - 1; ++j) {
		// Если текущий элемент меньше или равен опорному
		if (arr[j] <= pivot) {
			++i; // Увеличиваем индекс меньшего элемента
			std::swap(arr[i], arr[j]);
		}
	}

	std::swap(arr[i + 1], arr[high]);
	return i + 1;
}

// Рекурсивная функция для выполнения быстрой сортировки
template <class vec, class typ>
void quickSort(vec& arr, typ low, typ high) {
	if (low < high) {
		// Индекс опорного элемента после разделения
		typ pivotIndex = partition<vec, typ>(arr, low, high);

		// Рекурсивно сортируем элементы до и после опорного элемента
		quickSort<vec, typ>(arr, low, pivotIndex - 1);
		quickSort<vec, typ>(arr, pivotIndex + 1, high);
	}
}

// Функция разделяй и властвуй для сортировки слиянием с использованием потоков
template <class vec, class typ>
void parallelDivideAndConquerMergeSort(vec& array)
{
	typ size = array.size();
	typ numThreads = std::thread::hardware_concurrency();
	typ chunkSize = size / numThreads;

	std::vector<std::thread> threads;
	for (typ i = 0; i < numThreads; ++i) {
		typ start = i * chunkSize;
		typ end = (i == numThreads - 1) ? size - 1 : start + chunkSize - 1;
		threads.emplace_back(quickSort<vec, typ>, std::ref(array), start, end);
	}

	for (auto& thread : threads) {
		thread.join();
	}

	typ step = chunkSize;
	while (step < size) {
		for (typ i = 0; i < size - step; i += 2 * step) {
			typ left = i;
			typ mid = i + step;
			typ right = std::min<typ>(i + 2 * step, size);

			std::inplace_merge(array.begin() + left, array.begin() + mid, array.begin() + right);
		}
		step *= 2;
	}
}

int main() {
	SetConsoleCP(1251);
	SetConsoleOutputCP(1251);

	for (size_t i = 1; i <= 10; ++i)
	{
		size_t size_vector = i * (size_t)1e7;
		std::vector<int> arr(size_vector);
		std::iota(arr.begin(), arr.end(), 0);
		std::mt19937_64 urng{ 121216 };
		std::shuffle(arr.begin(), arr.end(), urng);

		auto start = std::chrono::steady_clock::now();
		//quickSort(arr, (size_t) 0, size_vector);
		parallelDivideAndConquerMergeSort<std::vector<int>, long long>(arr);
		auto end = std::chrono::steady_clock::now();

		std::cout << "Время выполнения: "
			<< std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
			<< " миллисекунд" << std::endl;

		if (std::is_sorted(arr.begin(), arr.end()))
			std::cout << "Массив отсортирован";
		else
			std::cout << "Не отсортирован массив";
		std::cout << std::endl;

	}

	return 0;
}

Комментарии (16)


  1. Politura
    08.07.2023 16:35

    У графиков подписи одинаковые и вывод отличается от максимальной цифры в табличке :)


    1. MaksSun Автор
      08.07.2023 16:35

      Исправил.


  1. SemmZemm
    08.07.2023 16:35
    +12

    а std::sort(std::execution::par_unseq,....) за сколько посчитает?


    1. ohai2u
      08.07.2023 16:35
      +1

      у меня получается, что std:sort with par_unseq быстрее примерно в два раза.

      divideand... не может на 100% нагрузить cpu, а std::sort может

      Время выполнения (divide): 2246 миллисекунд, для размера 10000000
      Время выполнения (std_sort, par_unseq): 1130 миллисекунд, для размера 10000000
      Время выполнения (divide): 4575 миллисекунд, для размера 20000000
      Время выполнения (std_sort, par_unseq): 2350 миллисекунд, для размера 20000000
      Время выполнения (divide): 6874 миллисекунд, для размера 30000000
      Время выполнения (std_sort, par_unseq): 3544 миллисекунд, для размера 30000000
      Время выполнения (divide): 9172 миллисекунд, для размера 40000000
      Время выполнения (std_sort, par_unseq): 4878 миллисекунд, для размера 40000000
      Время выполнения (divide): 11478 миллисекунд, для размера 50000000
      Время выполнения (std_sort, par_unseq): 6222 миллисекунд, для размера 50000000


    1. TheGast
      08.07.2023 16:35

      Ну так всегда лучше использовать стандартные алгоритмы. Тут человек явно для примера всё пишет.


    1. MaksSun Автор
      08.07.2023 16:35

      Не ставил себе такую задачу. Чтобы посчитать par_unseq надо С++ 17 стандарт врублять и новую библиотеку подключать.


  1. Tuxman
    08.07.2023 16:35

    Пересказ статьи MapReduce?


    1. tm1218
      08.07.2023 16:35

      MapReduce полностью параллельно выполняемый, а здесь только начальное разбиение массива


  1. DirectoriX
    08.07.2023 16:35

    С учётом того, как у вас в конце отсортированные массивчики объединяются в один большой массив за... наверно, n log n проходов, пожалуй было бы эффективнее первым действием разбить массив на куски, примерно как первый шаг быстрой сортировки, только не на 2, а наnumThreads частей - потом не пришлось бы сливать. То есть, если у вас массив содержит числа от 0 до 100, то при numThreads == 4первым шагом вы делите его на 4 куска (скажем, 0-20, 21-60, 61-77, 78-99), а потом быстро-сортируете уже эти куски.

    А может и не было бы быстрее, надо тестировать.

    Ну и да, как бы ни было заманчиво ускорение от потоков, настоящий прирост оно даст или при огромных массивах (обычно массивы сильно короче десятков миллионов (но, конечно, зависит от предметной области), так что расходы на запуск тредов всю выгоду съедят), либо при наличии пула этих самых тредов, чтоб создать 1 раз, а затем переиспользовать.


    1. Politura
      08.07.2023 16:35
      +3

      за... наверно, n log n проходов

      не, каждый цикл for - один проход по всему целому массиву, step изначально равен размеру чанка, после каждого цикла удваивается, так что должен быть n log k, где k - количество потоков.

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


  1. Mingun
    08.07.2023 16:35

    Ну и где же тут быстрая сортировка? Когда у вас в листингах сортировка слиянием используется… Да ещё и на первом и втором этапах листинг один и тот же. Хотя нет, разные, строчка с вызовом сортировки отличается — на первом этапе std::inplace_merge, а на втором — (самописная?) merge


    1. MaksSun Автор
      08.07.2023 16:35

      Не понял, где вы нашли самописанную merge?


      1. Mingun
        08.07.2023 16:35

        В листинге этапа 2 есть строчка merge<vec, typ>(array, left, mid, right);единственная строчка, которой этот листинг отличается от листинга этапа 1. Опа! Вы уже поменяли листинг этапа 1… Что это, как не вызов самописной merge?


  1. MaksSun Автор
    08.07.2023 16:35

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


    1. DirectoriX
      08.07.2023 16:35
      +2

      Для "фундаментальности" этого исследования не хватает анализа области применимости и ответов на смежные вопросы.

      • Какого размера массивы обычно сортируются (5, 50, 10 000, 10 000 000 000 элементов?) и начиная с какого количества элементов распараллеленный алгоритм начинает выигрывать у обычного?

        • Как эта величина зависит от количества потоков?
          Не буду же я запускать 16 потоков, чтоб отсортировать вектор на 50 элементов, верно?

      • Как изменится время работы, если вместо int у нас будут структуры с несколькими полями (хотя бы типичное фамилия-имя-отчество-год рождения), и для сравнения будет применяться не просто арифметическое сравнение, а функция (перегруженный оператор или лямбда)? Скорее всего лишь пропорционально замедлится, но вдруг нет?

      • Как "ускорение" отличается на Windows и Linux, у них ведь потоки сильно по-разному работают.

      • Почему для 70М элементов ускорение получилось больше, чем для 100М (4,11 против 4,00)?

      Ну и в чистом виде быстрая сортировка в C++ не используется, std::sort (по крайней мере в GCC) внутри себя использует introsort - потому что уже в С++11 есть требование заканчивать сортировку за не более чем O(n log n) операций, а быстрая сортировка может скатиться до O(n^2). Поэтому, кстати, std::sort обычно заметно быстрее qsort (можете сами протестировать).

      То есть как демонстрация того, что в каких-то отдельных случаях многопоточность помогает - хорошо, наглядно. Но назвать это исследованием я не решусь.


  1. WRP
    08.07.2023 16:35

    “VectorType pivot = arr[high]; // Выбираем последний элемент в качестве опорного”

    A почему не arr[high-1] ?