QtConcurrent обеспечивает более длительное время выполнения для нескольких ядер - PullRequest
0 голосов
/ 28 июня 2018

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

algorithm.h

 class Algorithm
 {
 public:
    Algorithm() : mDummy(0) {};
    void runAlgorithm();

protected:
    long mDummy;
};

algorithm.cpp

    #include "algorithm.h"

    void Algorithm::runAlgorithm()
    {
        long long k = 0;
        for (long long i = 0; i < 200000; ++i)
        {
            for (long long j = 0; j < 200000; ++j)
            {
                k = k + i - j;
            }
        }
        mDummy = k;
    }

main.cpp

    #include "algorithm.h"
    #include <QtCore/QCoreApplication>
    #include <QtConcurrent/QtConcurrent>

    #include <vector>
    #include <fstream>
    #include <QFuture>
    #include <memory>

    int main(int argc, char *argv[])
    {
        QCoreApplication a(argc, argv);
        std::ofstream logFile;
        logFile.open("AlgorithmLog.log", std::ios::trunc | std::ios::out);
        if (!logFile.is_open())
        {
            return 1;
        }

        for (int i = 1; i < 8; i++)
        {
            int cores = i;
            logFile << "Start: cores = " << cores << "   " << QDateTime::currentDateTime().toString(Qt::ISODate).toLatin1().data() << "\n";

            std::vector<std::unique_ptr<Algorithm>> cvAlgorithmRuns;
            for (int j = 0; j < cores; ++j)
                cvAlgorithmRuns.push_back(std::unique_ptr<Algorithm>(new Algorithm()));

            QFuture<void> assyncCalls = QtConcurrent::map(cvAlgorithmRuns, [](std::unique_ptr<Algorithm>& x) { x->runAlgorithm(); });
            assyncCalls.waitForFinished();

            logFile << "End: " << QDateTime::currentDateTime().toString(Qt::ISODate).toLatin1().data() << "\n";
            logFile.flush();
        }
        logFile.close();
        return a.exec();
    }

Когда я запускаю это на своем ноутбуке (я использую VS2015, x64, Qt 5.9.0, 8 логических процессоров), я получаю:

Start: cores = 1   2018-06-28T10:48:30 End: 2018-06-28T10:48:44
Start: cores = 2   2018-06-28T10:48:44 End: 2018-06-28T10:48:58
Start: cores = 3   2018-06-28T10:48:58 End: 2018-06-28T10:49:13
Start: cores = 4   2018-06-28T10:49:13 End: 2018-06-28T10:49:28
Start: cores = 5   2018-06-28T10:49:28 End: 2018-06-28T10:49:43
Start: cores = 6   2018-06-28T10:49:43 End: 2018-06-28T10:49:58
Start: cores = 7   2018-06-28T10:49:58 End: 2018-06-28T10:50:13

Что имеет смысл: одно и то же время выполнения (от 14 до 15 секунд) для всех шагов, использую ли я 1 или 7 ядер.

Но когда я меняю строку в algoritm.h с:

protected:
    long mDummy;

до:

protected:
    double mDummy;

Я получаю эти результаты:

Start: cores = 1   2018-06-28T10:52:30 End: 2018-06-28T10:52:44
Start: cores = 2   2018-06-28T10:52:44 End: 2018-06-28T10:52:59
Start: cores = 3   2018-06-28T10:52:59 End: 2018-06-28T10:53:15
Start: cores = 4   2018-06-28T10:53:15 End: 2018-06-28T10:53:32
Start: cores = 5   2018-06-28T10:53:32 End: 2018-06-28T10:53:53
Start: cores = 6   2018-06-28T10:53:53 End: 2018-06-28T10:54:14
Start: cores = 7   2018-06-28T10:54:14 End: 2018-06-28T10:54:38

Здесь я начинаю с 14 секунд времени выполнения для 1 ядра, но время работы увеличивается до 24 секунд при использовании 7 ядер.

Кто-нибудь может объяснить, почему при втором запуске увеличивается время выполнения при использовании нескольких ядер?

1 Ответ

0 голосов
/ 28 июня 2018

Я полагаю, что проблема заключается в фактическом количестве имеющихся у вас FPU, как предложено @Aconcagua. " логические процессоры " aka " гиперпоточность " - это не то же самое, что иметь вдвое больше ядер.

8 ядер в гиперпоточности - это еще 4 "настоящих" ядра. Если вы внимательно посмотрите на время, вы увидите, что время выполнения почти одинаково, пока вы не используете более 4 потоков. Когда вы используете более 4 потоков, вы можете начать исчерпывать FPU.

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

Когда мы хотим измерить необработанную производительность, мы должны помнить, что наш код C ++ - это просто представление более высокого уровня , и фактический исполняемый файл может сильно отличаться от того, что мы ожидаем.

Компилятор выполнит свою оптимизацию, ЦП выполнит все не по порядку и т. Д. ...

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

Например, код:

int main()
{
    int z = 0;
    for(int k=0; k < 1000; k++)
        z += k;

    return z;
}

компилируется GCC 8.1 с оптимизацией -O2 как:

main:
  mov eax, 499500
  ret

Как видите, петля исчезла !

Компилятор заменил его фактическим конечным результатом.

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

MSVC не настолько агрессивен, но вы никогда точно не знаете, что делает оптимизатор, если только вы не посмотрите на код сборки .

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

Простой способ решить эту проблему - использовать отличный проводник компилятора . Просто введите свой код C / C ++, выберите компилятор, который вы хотите использовать, и посмотрите результат.

Теперь вернемся к вашему коду, я протестировал его с помощью компилятора, используя MSVC2015 для x86_64.

Без оптимизаций код ассемблера выглядит практически одинаково, за исключением встроенного в конце кода для преобразования в double (cvtsi2sd).

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

Компиляция с флагом -O2 , код сборки, генерируемый, когда mDummy имеет значение long (32 бита), равен:

Algorithm::runAlgorithm, COMDAT PROC
        xor      r8d, r8d
        mov      r9d, r8d
        npad     10
$LL4@runAlgorit:
        mov      rax, r9
        mov      edx, 100000          ; 000186a0H
        npad     8
$LL7@runAlgorit:
        dec      r8
        add      r8, rax
        add      rax, -4
        sub      rdx, 1
        jne      SHORT $LL7@runAlgorit
        add      r9, 2
        cmp      r9, 400000             ; 00061a80H
        jl       SHORT $LL4@runAlgorit
        mov      DWORD PTR [rcx], r8d
        ret      0
Algorithm::runAlgorithm ENDP

конец, когда mDummy является float :

Algorithm::runAlgorithm, COMDAT PROC
        mov      QWORD PTR [rsp+8], rbx
        mov      QWORD PTR [rsp+16], rdi
        xor      r10d, r10d
        xor      r8d, r8d
$LL4@runAlgorit:
        xor      edx, edx
        xor      r11d, r11d
        xor      ebx, ebx
        mov      r9, r8
        xor      edi, edi
        npad     4
$LL7@runAlgorit:
        add      r11, -3
        add      r10, r9
        mov      rax, r8
        sub      r9, 4
        sub      rax, rdx
        dec      rax
        add      rdi, rax
        mov      rax, r8
        sub      rax, rdx
        add      rax, -2
        add      rbx, rax
        mov      rax, r8
        sub      rax, rdx
        add      rdx, 4
        add      r11, rax
        cmp      rdx, 200000          ; 00030d40H
        jl       SHORT $LL7@runAlgorit
        lea      rax, QWORD PTR [r11+rbx]
        inc      r8
        add      rax, rdi
        add      r10, rax
        cmp      r8, 200000             ; 00030d40H
        jl       SHORT $LL4@runAlgorit
        mov      rbx, QWORD PTR [rsp+8]
        xorps    xmm0, xmm0
        mov      rdi, QWORD PTR [rsp+16]
        cvtsi2ss xmm0, r10
        movss    DWORD PTR [rcx], xmm0
        ret      0
Algorithm::runAlgorithm ENDP

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

В частности, вторая версия (та, в которой mDummy является float):

  • немного длиннее
  • использует больше регистров
  • чаще обращайтесь к памяти

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

Более того, такие вещи, как турбо-буст могут также появиться При нагрузке ваш процессор может замедляться, что приводит к увеличению общего времени выполнения.

Для записей это то, что производит clang с включенной оптимизацией:

Algorithm::runAlgorithm(): # @Algorithm::runAlgorithm()
  mov dword ptr [rdi], 0
  ret

Confused? Ну ... никто не использует mDummy в другом месте, поэтому Clang решил полностью удалить все это ...:)

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...