【问题标题】:Summing numbers in parallel takes twice as long as the serial version并行求和数字的时间是串行版本的两倍
【发布时间】:2017-02-14 04:39:15
【问题描述】:

我的代码中有一个 const LONGLONG UPPER = 1000000000;,我正在尝试计算从 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(&cs_sum);
    g_sum += sum;
    LeaveCriticalSection(&cs_sum);

    return 0;
}

这是我用来计算的代码:

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

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

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

但是我得到了奇怪的结果:当我在一个简单的(串行)for 循环中对数字求和时,它的速度是多线程版本的两倍。当我将UPPER 乘以 10 并将线程数增加到 40 时,多线程版本甚至不会停止(大约 20 分钟后)。这是什么原因?

【问题讨论】:

  • 如果只使用等于核心数或核心数减1的线程数会怎样?
  • 我有 2 个物理处理器和 4 个逻辑处理器 - 我使用 2、3 和 4 个线程得到相同的结果。
  • 有点相关,有很多更快的方法来计算顺序和。想一想:1 到 10。这与 (1+10) + (9+2) + (8+3) + (7+4) + (6+5) 相同。换句话说,它是 11 * 5 = 55。一次乘法而不是 9 次加法,最重要的是,没有循环。考虑如何将其应用于您的整体任务。
  • 是的,正如我所说,我对公式很熟悉,但我将其作为练习。
  • 在这种情况下,您在临界区中所做的事情非常简单,无需任何人也可以完成:请参阅 InterlockedAdd 函数。可能值得做出改变,看看它如何影响时间。

标签: c++ c windows multithreading winapi


【解决方案1】:

有几件事代表了潜在的罪魁祸首。

首先(这通常是最重要的),检查您启用了哪些编译器优化。在编译器优化方面,有两件事通常非常正确:

  • 他们非常擅长优化“累积循环”,这就是您在这段代码中所做的。事实上,根据编译器的不同,它们可能会展开循环,或者使用 SIMD 操作来加快整个过程。
  • 无论代码多么简单,它们都不擅长优化任何类型的多线程代码。

我在处理单线程和多线程累加器时发现了类似的结果,并且在关闭优化时结果通常相反(多线程代码变得更快)。

作为一个案例研究,考虑编写比“将xy 之间的所有数字相加”更简单的代码,看看多线程代码是否突然变得更有效率。我的预测是会的,因为编译器会失去优化串行代码的方法。

其次,虽然这对于大多数用例(可能不是您的用例)来说通常并不代表一个大问题,但值得注意的是,启动新线程通常会涉及一定数量的开销。这值得牢记。

最后一个建议是准确评估如何您正在执行计算。如果你写过这样的代码:

size_t sum = 0;
std::mutex mutex;
std::thread t1([&]{for(size_t i = 0; i < 1'000'000; i++) {mutex.lock(); sum+=i; mutex.unlock();}});
std::thread t2([&]{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([&]{for(size_t i = 0; i < 1'000'000; i++) {s1 += i;} mutex.lock(); sum += s1; mutex.unlock();});
std::thread t2([&]{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([&]{for(size_t i = 0; i < 1'000'000; i++) {s1 += i;}});
std::thread t2([&]{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】:

    假脱机线程的成本很高。

    我敢打赌,在这种情况下,对运行时间的最大影响是编译器优化和分支预测。在这个用例中,这两者在串行版本中都会明显更好。

    【讨论】:

    • 我也有同样的想法,并询问 OP 实际花费了多少时间。他说串行版本需要 3 秒,并行版本需要 6 秒。我怀疑启动 10 个线程需要 3 秒。
    • 分支预测在任一版本中几乎相同。您将有 分支未命中。将其与迭代次数进行比较,您会发现,这几乎无法测量。
    • @NathanOliver 当然,最初的启动不会花费那么长时间,但是对于像这样的微不足道的任务,仍然值得考虑线程方法的开销。
    • @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, &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);
    

    【讨论】:

    • 所以您的建议只是消除关键部分锁定?不要认为这会做任何事情,因为只进入关键部分 10 次加一(是的,即使是 g_sum = 0; 命令放在他的主线程中的 OP 也应该放在关键部分中)并不是真的开销很大,实际上甚至无法衡量。
    猜你喜欢
    • 1970-01-01
    • 2018-03-17
    • 1970-01-01
    • 1970-01-01
    • 2011-02-07
    • 1970-01-01
    • 1970-01-01
    • 2023-03-19
    • 1970-01-01
    相关资源
    最近更新 更多