Параллельное суммирование чисел занимает в два раза больше времени, чем последовательная версия

#c #c #windows #multithreading #winapi

#c #c #Windows #многопоточность #winapi

Вопрос:

const LONGLONG UPPER = 1000000000; В моем коде есть a, и я пытаюсь вычислить сумму всех чисел от 1 до UPPER (да, я знаю, что для этого есть формула).

Это мои глобальные переменные:

 const LONGLONG UPPER = 1000000000;
const int NUM = 10; // number of threads
LONGLONG g_sum;
CRITICAL_SECTION cs_sum;
  

Это моя функция потока:

 DWORD WINAPI SumThread(PVOID pvParam) {
    LONGLONG i;
    LONGLONG sum = 0;
    LONGLONG x = (LONGLONG)pvParam;

    x = x * (UPPER / NUM);

    for (i = x   1; i <= x   UPPER / NUM; i  ) {
        sum  = i;
    }

    EnterCriticalSection(amp;cs_sum);
    g_sum  = sum;
    LeaveCriticalSection(amp;cs_sum);

    return 0;
}
  

И это код, который я использую для выполнения вычислений:

 HANDLE* hThreads = (HANDLE*)(malloc(sizeof(HANDLE) * NUM));
g_sum = 0;

InitializeCriticalSection(amp;cs_sum);
for (i = 0; i < NUM; i  ) {
    hThreads[i] = CreateThread(NULL, 0, SumThread, (PVOID)i, 0, NULL);
}

WaitForMultipleObjects(NUM, hThreads, TRUE, INFINITE);
DeleteCriticalSection(amp;cs_sum);
  

Но я получаю странные результаты: когда я суммирую числа в простом (последовательном) for цикле, это в два раза быстрее, чем многопоточная версия. Когда я умножаю UPPER на 10 и увеличиваю количество потоков до 40, многопоточная версия даже не останавливается (примерно через 20 минут). В чем причина этого?

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

1. Что произойдет, если вы используете только количество потоков, равное количеству ядер или количеству ядер минус 1?

2. У меня 2 физических и 4 логических процессора — я получаю те же результаты с 2, 3 и 4 потоками.

3. В некоторой степени связанные с этим, существуют гораздо более быстрые способы вычисления последовательной суммы. Подумайте об этом: от 1 до 10. Это было бы то же самое, что (1 10) (9 2) (8 3) (7 4) (6 5). Другими словами, это 11 * 5 = 55. Одно умножение вместо 9 сложений и, самое главное, отсутствие цикла . Подумайте, как вы могли бы применить это к своей общей задаче.

4. Да, как я уже сказал, я знаком с формулой, но я делаю это в качестве упражнения.

5. В этом случае то, что вы делаете внутри критической секции, настолько просто, что это можно сделать без нее: см. Функцию InterlockedAdd . Возможно, стоит внести это изменение, просто чтобы посмотреть, как это влияет на время.

Ответ №1:

Есть несколько вещей, которые представляют потенциальных виновников.

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

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

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

В качестве примера рассмотрите возможность написания кода, который немного менее тривиален, чем «сложите все числа вместе между x и y «, и посмотрите, станет ли многопоточный код внезапно более эффективным. Я предсказываю, что так и будет, потому что компилятор потеряет способы оптимизации последовательного кода.

Во-вторых, хотя это обычно не представляет большой проблемы для большинства вариантов использования (возможно, не для вас), стоит отметить, что запуск новых потоков обычно сопряжен с определенными накладными расходами. Это стоит иметь в виду.

Последнее предложение — точно оценить, как вы выполняете вычисления. Если бы вы написали такой код:

 size_t sum = 0;
std::mutex mutex;
std::thread t1([amp;]{for(size_t i = 0; i < 1'000'000; i  ) {mutex.lock(); sum =i; mutex.unlock();}});
std::thread t2([amp;]{for(size_t i = 1'000'000; i < 2'000'000; i  ) {mutex.lock(); sum =i; mutex.unlock();}});
t1.join();
t2.join();
std::cout << "Sum of integers between 0 and 1999999: " << sum << std::endl;
  

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

 size_t sum = 0;
size_t s1 = 0, s2 = 0;
std::mutex mutex;
std::thread t1([amp;]{for(size_t i = 0; i < 1'000'000; i  ) {s1  = i;} mutex.lock(); sum  = s1; mutex.unlock();});
std::thread t2([amp;]{for(size_t i = 1'000'000; i < 2'000'000; i  ) {s2  = i;}mutex.lock(); sum  = s2; mutex.unlock();});
t1.join();
t2.join();
std::cout << "Sum of integers between 0 and 1999999: " << sum << std::endl;
  

Вы могли бы (акцент на слове «может») быть в состоянии получить незначительное ускорение, если вместо этого вы напишете это так (поскольку мьютексы / критические разделы обычно являются основными узкими местами в производительности):

 size_t sum = 0;
size_t s1 = 0, s2 = 0;
std::thread t1([amp;]{for(size_t i = 0; i < 1'000'000; i  ) {s1  = i;}});
std::thread t2([amp;]{for(size_t i = 1'000'000; i < 2'000'000; i  ) {s2  = i;}});
t1.join();
t2.join();
sum = s1   s2;
std::cout << "Sum of integers between 0 and 1999999: " << sum << std::endl;
  

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

Ответ №2:

Буферизация потоков является дорогостоящей.

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

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

1. У меня была та же мысль, и я спросил OP, сколько времени это действительно заняло. Он утверждает, что последовательная версия занимает 3 секунды, а параллельная — 6. Я сомневаюсь, что для запуска 10 потоков требуется 3 секунды.

2. Прогнозирование ветвления будет почти идентичным в любой версии. У вас будет <количество потоков> пропусков ветвей. Сравните это с количеством итераций, и вы увидите, что это вряд ли даже измеримо.

3. @NathanOliver конечно, начальный запуск не займет так много времени, но для такой тривиальной задачи, как эта, все же стоит учитывать накладные расходы при поточном подходе.

4. @jhbh — Если общее время составляет 3 секунды для последовательного, потоковое решение должно работать лучше.

Ответ №3:

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

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

Попробуйте что-то вроде этого:

 const LONGLONG UPPER = 1000000000;
const int NUM = 10; // number of threads

struct threadInfo
{
    LONGLONG start;
    LONGLONG sum;
};

DWORD WINAPI SumThread(PVOID pvParam) {
    struct threadInfo* pInfo = (struct threadInfo*) pvParam;
    LONGLONG i, sum = 0, x = pInfo->start;

    x *= (UPPER / NUM);

    for (i = x   1; i <= x   UPPER / NUM;   i) {
        sum  = i;
    }

    pInfo->sum = sum;
    return 0;
}

struct threadInfo* pInfo = (struct threadInfo*) malloc (sizeof(struct threadInfo) * NUM);

HANDLE* hThreads = (HANDLE*) malloc(sizeof(HANDLE) * NUM);

for (int i = 0; i < NUM;   i) {
    pInfo[i].start = i;
    hThreads[i] = CreateThread(NULL, 0, SumThread, amp;pInfo[i], 0, NULL);
}

WaitForMultipleObjects(NUM, hThreads, TRUE, INFINITE);

LONGLONG sum = 0;
for (int i = 0; i < NUM;   i) {
    sum  = pInfo[i].sum;
    CloseHandle(hThreads[i]);
}

free(pInfo);
free(hThreads);
  

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

1. Итак, ваше предложение просто устранить блокировку критического раздела? Не думайте, что это что-нибудь даст, так как ввод критической секции только 10 раз плюс один (да, даже та g_sum = 0; команда, которую OP поместил в свой основной поток, также должна была быть помещена в критическую секцию), на самом деле не является большими накладными расходами, на самом деле это даже не поддается измерению.