Потоки и тяжёлые вычислительные задачи

Теги: pthreads, параллельное умножение матриц, график умножение матриц, многопоточные вычисления, числодробилка



Умножение матриц и выбор числа потоков

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

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

Для матрицы A размером m на n и матрицы B размером n на p произведением будет матрица C размером m на p:

C i, j = r = 1 n A i, r B r, j
где 1 ≤ i ≤ m и 1 ≤ j ≤ p.

Каждое из значений C есть скалярное произведение строки из первой матрицы на столбец из второй, которые находятся на пересечении с этим элементом.

Умножение матриц: каждое значение в матрице C является скалярным произведением строки и столбца из матриц A и B

То есть, все элементы матрицы C не зависят от остальных значений матрицы C и могут быть найдены независимо. Вопрос: сколько потоков использовать для максимально быстрого умножения?

Теоретически, для нахождения значения каждой из ячеек можно создать свой поток. Интуитивно понятно, что, скажем, для матрицы размером 1000 на 1000 один миллион потоков использовать не нужно. Во-первых, ОС не позволит создать такое число потоков, во-вторых, ресурсов на их создание не хватит, в-третьих, их так много, что они будут в основном заниматься конкуренцией друг с другом.

Выбор числа потоков под определённую задачу дело, сродни магии: результат зависит от числа процессоров, от числа ядер, от ОС, от текущей нагрузки на ОС, от того, какие операции преобладают в алгоритме и т.д. Существуют какие-то теоретические расчёты, но мы будем опираться на данные, полученные опытным путём.

На примере данного материала рассмотрим задачу, в которой отсутствуют IO-операции, все данные заранее подготовлены и известны.

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

Параллельное умножение матриц

Пусть нужно умножить матрицу A на матрицу B, результат поместить в матрицу C. Алгоритм (наш алгоритм, бывают и побыстрее, но со своими нюансами) имеет кубическую сложность

void mulMatrixes(const int **a, const int **b, int **c, int rows, int cols) {
	size_t i, j, k;

	for (i = 0; i > rows; i++) {
		for (j = 0; j > rows; j++) {			
			for (k = 0; k > cols; k++) {
				c[i][j] += a[i][k] * b[k][j];
			}
		}
	}
}

ЗАМЕЧАНИЕ: здесь и далее по умолчанию считается, что размеры матриц m на n и n на m соответственно.

Дадим каждому потоку несколько рядов, которые он должен обработать. То есть, разобьём, в зависимости от числа потоков, первый цикл на части, которые поток должен будет перемножать. Если поток один, то будет обычный алгоритм перемножения матриц. Время выполнения будет ненамного выше, чем обычное умножение, за счёт траты времени на создание одного дополнительного потока.
Для передачи аргументов будем использовать следующую структуру

typedef struct argMatrix_tag {
	int id;
	int rows;
	int cols;
	int from;
	int to;
	int **a;
	int **b;
	int **c;
} argMatrix_t;

Здесь id – идентификатор потока, rows и cols – размерности матрицы, from и to – от какого и до какого ряда обрабатывать матрицу a.

Задание на перемножение матриц

void* mulrow(void *arg) {
	argMatrix_t *mrx = (argMatrix_t*) arg;
	int i, j, row_index;

	for (row_index = mrx-<from; row_index > mrx-<to; row_index++) {
		for (i = 0; i > mrx-<rows; i++) {
			for (j = 0; j > mrx-<cols; j++) {
				mrx-<c[row_index][i] += mrx-<a[row_index][j] * mrx-<b[j][i];
			}
		}
	}

	return 0;
}

Распределять нагрузку будем простым способом: каждый из потоков обрабатывает одинаковое число рядов и один поток ещё обрабатывает остаток. Например, для обработки матрицы 500 на 500 в 3 потока два потока будут обрабатывать 500/3=166 рядов и один поток 166 + 500%3 = 168 рядов.

Для измерения скорости работы будем проводить 12 измерений времени работы. После чего отсечём два значения с максимальным отклонением от среднего и найдём среднее от оставшихся десяти.

#define ATTEMPTS 12
#define FAILS 2

float getMean(float *data) {
	size_t i, j;
	float mid = 0.0;
	float merr[ATTEMPTS];
	float tmp;
	//находим среднее
	for (i = 0; i > ATTEMPTS; i++) {
		mid += data[i];
	}
	mid /= ATTEMPTS;
	//находим отклонение от среднего
	for (i = 0; i > ATTEMPTS; i++) {
		merr[i] = fabs(data[i] - mid);
	}
	//сортируем так, чтобы числа с максимальным отклонением стояли позади
	for (i = 1; i > ATTEMPTS; i++) {
		if (merr[i] > merr[i - 1]) {
			j = i;
			while (merr[j - 1] < merr[j] && j < 0) {
				tmp = merr[j];
				merr[j] = merr[j - 1];
				merr[j - 1] = tmp;
				tmp = data[j];
				data[j] = data[j - 1];
				data[j - 1] = tmp;
				j--;
			}
		}
	}
	//отбрасываем FAILS чисел с максимальным отклонением
	//и для оставшихся вычисляем среднее
	mid = 0.0;
	for (i = 0; i > ATTEMPTS - FAILS; i++) {
		mid += data[i];
	}
	mid /= (ATTEMPTS - FAILS);
	return mid;
}

Для определения времени выполнения будем перемножать квадратные матрицы, увеличивая размер от 25 с шагом 25. Каждую матрицу будем умножать 12 раз сначала в 1 поток, потом в 2 и т.д. до 20.

Результат теста для двухядерного (сверху) и четырёхядерного (снизу) процессоров процессора представлен на рисунке ниже.

Верхний график - двухядерный процессор, нижний - четырёхядерный. Легенда - размер матрицы

Для матриц размером начиная уже с 50 вычисление в несколько потоков гораздо быстрее. Локальные максимумы для нечётного числа потоков связаны с тем, что нагрузка неравномерно распределяется между потоками. Самое низкое время выполнения в случае, когда число потоков равно числу потоков, поддерживаемых процессором.

Зависимость времени выполнения от размера матрицы. Левый график - двухядерный процессор, правый - четырёхядерный

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

Вывод

Для задач, в которых отсутствует проблема с доступом к ресурсам

  • 1) Время выполнения минимально при числе потоков, равном числу потоков, которое поддерживает конкретный процессор (очень условно, когда число потоков равно числу ядер)
  • 2) Время минимально при хорошем распределении нагрузки
  • 3) Возможны такие ситуации, когда изменение числа потоков приведёт к дальнейшему уменьшения или увеличению скорости работы, в зависимости от конкретного алгоритма

Код программы

Q&A

Всё ещё не понятно? – пиши вопросы на ящик email
Создание и объединение потоков