При выполнении расчета — сколько потоков я должен открыть?

Я пишу программу, которая выполняет длинные вычисления, которые я могу разбить на столько задач, сколько захочу. Для обсуждения давайте предположим, что я пишу алгоритм для определения, является ли число p простым, пытаясь разделить его на все числа от 2 до p-1. Эта задача, очевидно, может быть разбита на множество потоков.

Я на самом деле написал пример приложения, которое делает именно это. В качестве параметра я даю число, которое я хочу проверить, и количество потоков, которые нужно использовать (каждому потоку дается диапазон одинакового размера чисел, на который нужно попытаться разделить p — вместе они охватывают весь диапазон).

У моей машины 8 ядер. Я начал запускать программу с большим числом, которое, как я знаю, является простым (2971215073), и с 1, 2, 3 потоками и т. Д. До достижения 8 потоков — каждый раз, когда программа работала быстрее, чем предыдущая, что я и ожидал. Тем не менее, когда я пробовал числа больше 8, время вычислений становилось все меньше (даже если немного)!

В моих потоках нет ни ввода-вывода, ни чего-либо подобного, только вычисления процессора. Я ожидал, что время выполнения ухудшится, когда я пропущу 8 потоков, поскольку будет больше переключения контекста, а число параллельно работающих потоков останется равным 8. Трудно сказать, где находится пик, поскольку различия очень малы и меняются от одного запуска к другому, однако ясно, что, например, 50 потоков как-то работают быстрее, чем 8 (на ~ 300 мс) …

Я предполагаю, что, поскольку у меня так много потоков, я получаю больше времени выполнения, так как у меня большая часть в системном пуле потоков, поэтому мои потоки выбираются больше. Тем не менее, кажется, что не имеет смысла, что чем больше потоков я создаю, тем быстрее работает программа (иначе почему бы не каждому создать 1000 потоков ??).

Может ли кто-нибудь предложить объяснение и, возможно, лучшую практику относительно того, сколько потоков создать относительно количества ядер на машине?

Благодарю.


Мой код для тех, кто заинтересован (скомпилировано на Windows, VS2012):

#include <Windows.h>
#include <conio.h>
#include <iostream>
#include <thread>
#include <vector>

using namespace std;

typedef struct
{
unsigned int primeCandidate;
unsigned int rangeStart;
unsigned int rangeEnd;
} param_t;DWORD WINAPI isDivisible(LPVOID p)
{
param_t* param = reinterpret_cast<param_t*>(p);

for (unsigned int d = param->rangeStart; d < param->rangeEnd; ++d)
{
if (param->primeCandidate % d == 0)
{
cout << param->primeCandidate << " is divisible by " << d << endl;
return 1;
}
}

return 0;
}

bool isPrime(unsigned int primeCandidate, unsigned int numOfCores)
{
vector<HANDLE> handles(numOfCores);
vector<param_t> params(numOfCores);
for (unsigned int i = 0; i < numOfCores; ++i)
{
params[i].primeCandidate = primeCandidate;
params[i].rangeStart = (primeCandidate - 2) * (static_cast<double>(i) / numOfCores) + 2;
params[i].rangeEnd = (primeCandidate - 2) * (static_cast<double>(i+1) / numOfCores) + 2;
HANDLE h = CreateThread(nullptr, 0, reinterpret_cast<LPTHREAD_START_ROUTINE>(isDivisible), &params[i], 0, 0);
if (NULL == h)
{
cout << "ERROR creating thread: " << GetLastError() << endl;
throw exception();
}
handles[i] = h;
}

DWORD ret = WaitForMultipleObjects(numOfCores, &handles[0], TRUE, INFINITE);
if (ret >= WAIT_OBJECT_0 && ret <= WAIT_OBJECT_0 + numOfCores - 1)
{
for (unsigned int i = 0; i < numOfCores; ++i)
{
DWORD exitCode = -1;
if (0 == GetExitCodeThread(handles[i], &exitCode))
{
cout << "Failed to get thread's exit code: " << GetLastError() << endl;
throw exception();
}

if (1 == exitCode)
{
return false;
}
}

return true;
}
else
{
cout << "ERROR waiting on threads: " << ret << endl;
throw exception();
}
}

int main()
{
unsigned int primeCandidate = 1;
unsigned int numOfCores = 1;

cout << "Enter prime candidate: ";
cin >> primeCandidate;
cout << "Enter # of cores (0 means all): ";
cin >> numOfCores;
while (primeCandidate > 0)
{
if (0 == numOfCores) numOfCores = thread::hardware_concurrency();

DWORD start = GetTickCount();
bool res = isPrime(primeCandidate, numOfCores);
DWORD end = GetTickCount();
cout << "Time: " << end-start << endl;
cout << primeCandidate << " is " << (res ? "" : "not ") << "prime!" << endl;

cout << "Enter prime candidate: ";
cin >> primeCandidate;
cout << "Enter # of cores (0 means all): ";
cin >> numOfCores;
}

return 0;
}

7

Решение

Да. Вот небольшой отрывок из нескольких тестов, которые я провел на своем компьютере с i7 / Vista 64 (4 «настоящих» ядра + гиперпоточность):

8 tests,
400 tasks,
counting to 10000000,
using 8 threads:
Ticks: 2199
Ticks: 2184
Ticks: 2215
Ticks: 2153
Ticks: 2200
Ticks: 2215
Ticks: 2200
Ticks: 2230
Average: 2199 ms

8 tests,
400 tasks,
counting to 10000000,
using 32 threads:
Ticks: 2137
Ticks: 2121
Ticks: 2153
Ticks: 2138
Ticks: 2137
Ticks: 2121
Ticks: 2153
Ticks: 2137
Average: 2137 ms

.. демонстрируя, что, как и в ваших тестах, «чрезмерная подписка» на потоки приводит к незначительному улучшению общего времени выполнения на 2-3%. Мои тесты отправляли простые «подсчитать целое» задачи, интенсивно использующие ЦП, в пул потоков с различным количеством потоков.

В то время я пришел к выводу, что незначительное улучшение состояло в том, что большее количество потоков заняло больший% возраста «базовой нагрузки» на моем компьютере — 1-4% нагрузки от нескольких из 1000 с лишним потоков в почти всегда бездействующий Firefox, uTorrent, Word, панель задач и т. д., которые во время тестов работали немного.

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

Это применимо только тогда, когда данные, используемые задачами, очень малы. Позже я повторил аналогичную серию тестов, где в задачах использовался массив 8K — размер кэша L1. В этом «наихудшем» сценарии использование большего количества потоков, чем ядер, приводило к очень заметному замедлению, пока при 16 потоках и выше производительность не снизилась на 40%, поскольку потоки обменивались всем кэшем. Выше 20 потоков замедление не ухудшалось, поскольку, независимо от того, сколько потоков выполняло задачи, кэш все равно выгружался из каждого ядра с одинаковой скоростью.

Обратите внимание, что у меня было много оперативной памяти и поэтому очень мало сбоев страниц.

5

Другие решения

Вы делаете предположение, что каждый поток имеет равный объем работы для выполнения, что на самом деле не может иметь место. То, на что вы должны обратить внимание, это время выхода каждого из ваших потоков. Если один или несколько из них выходят значительно раньше, чем остальные, будет иметь смысл, что добавление большего количества потоков ускорит его. То есть, если кто-то останавливается рано, это означает, что ядро ​​больше не будет использоваться, имея дополнительные потоки, это более справедливо распределяет нагрузку.

Есть несколько причин, по которым каждый поток может занимать разное время выполнения. Я не знаю времени выполнения команд в вашем коде, но, возможно, они являются переменными. Также вероятно, что каждый поток имеет свой набор оптимизаций ЦП, например, прогнозирование ветвлений. Можно просто потерять свой временный интервал для ОС или на мгновение остановиться на крошечном объеме памяти. Достаточно сказать, что существует множество факторов, которые могут сделать один медленнее другого.

Трудно сказать, какой из них лучший. В общем, вы хотели бы сохранить загрузку процессоров, поэтому вы, как правило, правы относительно N потоков для N ядер. Однако следует помнить о таких вещах, как гиперпоточность, когда на самом деле у вас нет лишних ядер — если только вы не используете много памяти, чего нет, гиперпоточность просто помешает. На более новых чипах AMD у них вдвое меньше FPU, так что ваши целочисленные инструкции в порядке, но с плавающей запятой может остановиться.

Если вы хотите, чтобы каждый процессор был загружен, единственный способ действительно сделать это — это основанная на работе структура. Разбейте ваши расчеты на более мелкие единицы (как и вы), но по-прежнему используйте только один поток на ядро Поскольку поток завершен со своей текущей работой, он должен взять следующую доступную работу. Таким образом, не имеет значения, если некоторые задания длиннее / короче, освобожденные процессоры просто перейдут к следующему заданию.

Это, конечно, имеет смысл, только если расчет длинный. Если общее время составляет всего несколько секунд, накладные расходы могут вызвать небольшое замедление. Но даже начиная с 4-5 секунд вы должны начать видеть усиление. Кроме того, убедитесь, что вы отключаете масштабирование частоты ЦП при выполнении небольших временных тестов, в противном случае время увеличения / уменьшения скорости каждого ЦП в основном даст вам случайные результаты.

1

По вопросам рекламы [email protected]