C++ threaded приложение работает медленнее, чем без резьбы

В настоящее время я пишу генератор простых чисел на C++. Сначала я сделал однопоточную версию, а затем многопоточную версию.

я узнал, что если моя программа генерирует значения меньше 100'000, однопоточная версия быстрее, чем многопоточная. Очевидно, я делаю что-то не так.

мой код ниже:

#include <iostream>
#include <fstream>
#include <set>
#include <string>
#include <thread>
#include <mutex>
#include <shared_mutex>

using namespace std;

set<unsigned long long> primeContainer;
shared_mutex m;

void checkPrime(const unsigned long long p)
{
    if (p % 3 == 0)
        return;

    bool isPrime = true;
    for (set<unsigned long long>::const_iterator it = primeContainer.cbegin(); it != primeContainer.cend(); ++it)
    {
        if (p % *it == 0)
        {
            isPrime = false;
            break;
        }
        if (*it * *it > p) // check only up to square root
            break;
    }

    if (isPrime)
        primeContainer.insert(p);
}

void checkPrimeLock(const unsigned long long p)
{
    if (p % 3 == 0)
        return;

    bool isPrime = true;
    try
    {
        shared_lock<shared_mutex> l(m);
        for (set<unsigned long long>::const_iterator it = primeContainer.cbegin(); it != primeContainer.cend(); ++it)
        {
            if (p % *it == 0)
            {
                isPrime = false;
                break;
            }
            if (*it * *it > p)
                break;
        }
    }
    catch (exception& e)
    {
        cout << e.what() << endl;
        system("pause");
    }

    if (isPrime)
    {
        try
        {
            unique_lock<shared_mutex> l(m);
            primeContainer.insert(p);
        }
        catch (exception& e)
        {
            cout << e.what() << endl;
            system("pause");
        }
    }
}

void runLoopThread(const unsigned long long& l)
{
    for (unsigned long long i = 10; i < l; i += 10)
    {
        thread t1(checkPrimeLock, i + 1);
        thread t2(checkPrimeLock, i + 3);
        thread t3(checkPrimeLock, i + 7);
        thread t4(checkPrimeLock, i + 9);
        t1.join();
        t2.join();
        t3.join();
        t4.join();
    }
}

void runLoop(const unsigned long long& l)
{
    for (unsigned long long i = 10; i < l; i += 10)
    {
        checkPrime(i + 1);
        checkPrime(i + 3);
        checkPrime(i + 7);
        checkPrime(i + 9);
    }
}

void printPrimes(const unsigned long long& l)
{
    if (1U <= l)
        cout << "1 ";
    if (2U <= l)
        cout << "2 ";
    if (3U <= l)
        cout << "3 ";
    if (5U <= l)
        cout << "5 ";

    for (auto it = primeContainer.cbegin(); it != primeContainer.cend(); ++it)
    {
        if (*it <= l)
            cout << *it << " ";
    }
    cout << endl;
}

void writeToFile(const unsigned long long& l)
{
    string name = "primes_" + to_string(l) + ".txt";
    ofstream f(name);

    if (f.is_open())
    {
        if (1U <= l)
            f << "1 ";
        if (2U <= l)
            f << "2 ";
        if (3U <= l)
            f << "3 ";
        if (5U <= l)
            f << "5 ";

        for (auto it = primeContainer.cbegin(); it != primeContainer.cend(); ++it)
        {
            if (*it <= l)
                f << *it << " ";
        }
    }
    else
    {
        cout << "Error opening file." << endl;
        system("pause");
    }
}

int main()
{
    unsigned int n = thread::hardware_concurrency();
    std::cout << n << " concurrent threads are supported." << endl;

    unsigned long long limit;
    cout << "Please enter the limit of prime generation: ";
    cin >> limit;

    primeContainer.insert(7);

    if (10 < limit)
    {
        //runLoop(limit); //single-threaded
        runLoopThread(limit); //multi-threaded
    }

    printPrimes(limit);
    //writeToFile(limit);
    system("pause");
    return 0;
}

на main функция вы найдете комментарии о том, какая функция однопоточная и многопоточная.

основное различие между ними заключается в использовании блокировок, общих для итерации контейнера и уникальных для вставки. Если это имеет значение, мой процессор имеет 4 ядра.

почему версия с одним потоком быстрее?

4 ответов


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

Я бы рекомендовал запустить эти 4 потока вне main for петля и процесс 1/4 из ряда в каждом потоке. Но для этого может потребоваться дополнительная синхронизация, потому что для проверки простого кода, по-видимому, требуется чтобы сначала иметь простые числа до sqrt N.

С моей точки зрения, было бы проще использовать сито Эрастофенов алгоритм, который может быть намного проще распараллелить без какой-либо блокировки (однако может по-прежнему страдать проблемой, известной как "ложного совместного использования").

редактировать

здесь я быстро создал версию, используя сито Erastothenes:

void processSieve(const unsigned long long& l,
    const unsigned long long& start,
    const unsigned long long& end,
    const unsigned long long& step,
    vector<char> &is_prime)
{
    for (unsigned long long i = start; i <= end; i += step)
        if (is_prime[i])
            for (unsigned long long j = i + i; j <= l; j += i)
                is_prime[j] = 0;
}

void runSieve(const unsigned long long& l)
{
    vector<char> is_prime(l + 1, 1);
    unsigned long long end = sqrt(l);
    processSieve(l, 2, end, 1, is_prime);
    primeContainer.clear();
    for (unsigned long long i = 1; i <= l; ++i)
        if (is_prime[i])
            primeContainer.insert(i);
}

void runSieveThreads(const unsigned long long& l)
{
    vector<char> is_prime(l + 1, 1);
    unsigned long long end = sqrt(l);
    vector<thread> threads;
    threads.reserve(cpuCount);
    for (unsigned long long i = 0; i < cpuCount; ++i)
        threads.emplace_back(processSieve, l, 2 + i, end, cpuCount, ref(is_prime));
    for (unsigned long long i = 0; i < cpuCount; ++i)
        threads[i].join();
    primeContainer.clear();
    for (unsigned long long i = 1; i <= l; ++i)
        if (is_prime[i])
            primeContainer.insert(i);
}

результаты измерения, простые числа вверх до 1 000 000 (MSVC 2013, выпуск):

runLoop: 204.02 ms
runLoopThread: 43947.4 ms
runSieve: 30.003 ms
runSieveThreads (8 cores): 24.0024 ms

до 10 0000 000:

runLoop: 4387.44 ms
// runLoopThread disabled, taking too long
runSieve: 350.035 ms
runSieveThreads (8 cores): 285.029 ms

времена включают окончательную обработку вектора и нажатие результатов на простое множество.

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

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


У вас есть несколько проблем.

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

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


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

vector<uint64_t> primes_thread1;
vector<uint64_t> primes_thread2;
...

// check all numbers in [start, end)
void check_primes(uint64_t start, uint64_t end, vector<uint64_t> & out) {
    for (auto i = start; i < end; ++i) {
        if (is_prime(i)) { // simply loop through all odds from 3 to sqrt(i)
            out.push_back(i);
        }
    }
}

auto f1 = async(check_primes, 1, 1000'000, ref(primes_thread1));
auto f2 = async(check_primes, 1000'000, 2000'000, ref(primes_thread2));
...

f1.wait();
f2.wait();
...

primes_thread1.insert(
    primes_thread1.begin(),
    primes_thread2.cbegin(), primes_thread2.cend()
);
primes_thread1.insert(
    primes_thread1.begin(),
    primes_thread3.cbegin(), primes_thread3.cend()
);
...
// primes_thread1 contains all primes found in all threads

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


в вашем первом тесте может быть еще одна проблема. Вы никогда не тестируете против 7 как делитель.

более того, ваш тест предполагает, что primeContainer уже содержит все простые числа между 10 и корнем sqare тестируемого числа. Это может быть не так, если вы используете потоки для заполнения контейнера.

Если вы заполняете контейнер всегда увеличивающимися числами (и ваш алгоритм рассчитывает на это), вы можете использовать std::vector вместо std:: set для более высокая производительность.