在c ++ 11 std :: thread中执行的时间开销是否取决于所执行的有效负载?

时间:2018-10-02 12:55:46

标签: linux multithreading performance c++11 benchmarking

我想知道与直接执行相比,在C ++ 11 std :: thread(或std :: async)中执行方法的时间开销。我知道线程池可以大大减少甚至完全避免这种开销。但是我仍然希望对数字有更好的感觉。我想大致了解一下线程创建能带来多少计算成本,以及池带来的收益。

我自己实现了一个简单的基准,归结为:

void PayloadFunction(double* aInnerRuntime, const size_t aNumPayloadRounds) {
    double vComputeValue = 3.14159;

    auto vInnerStart = std::chrono::high_resolution_clock::now();
    for (size_t vIdx = 0; vIdx < aNumPayloadRounds; ++vIdx) {
        vComputeValue = std::exp2(std::log1p(std::cbrt(std::sqrt(std::pow(vComputeValue, 3.14152)))));
    }
    auto vInnerEnd = std::chrono::high_resolution_clock::now();
    *aInnerRuntime += static_cast<std::chrono::duration<double, std::micro>>(vInnerEnd - vInnerStart).count();

    volatile double vResult = vComputeValue;
}

int main() {
    double vInnerRuntime = 0.0;
    double vOuterRuntime = 0.0;

    auto vStart = std::chrono::high_resolution_clock::now();
    for (size_t vIdx = 0; vIdx < 10000; ++vIdx) {
        std::thread vThread(PayloadFunction, &vInnerRuntime, cNumPayloadRounds);
        vThread.join();
    }
    auto vEnd = std::chrono::high_resolution_clock::now();
    vOuterRuntime = static_cast<std::chrono::duration<double, std::micro>>(vEnd - vStart).count();

    // normalize away the robustness iterations:
    vInnerRuntime /= static_cast<double>(cNumRobustnessIterations);
    vOuterRuntime /= static_cast<double>(cNumRobustnessIterations);

    const double vThreadCreationCost = vOuterRuntime - vInnerRuntime;
}

这很好用,在带有现代Core i7-6700K的Ubuntu 18.04上,我得到的典型线程创建成本约为20-80微秒(美国)。一方面,这比我的期望便宜!

但是现在出现了一个奇怪的部分:线程开销似乎取决于(非常可重现)有效负载方法中花费的实际时间!这对我来说毫无意义。但是可重现发生在六种不同的具有Ubuntu和CentOS风格的硬件机器上!

  1. 如果我在PayloadFunction内部花费1到100us,则典型的线程创建成本约为20us。
  2. 当我将在PayloadFunction中花费的时间增加到100-1000us时,线程创建成本将增加到40us左右。
  3. PayloadFunction中进一步增加到10000us以上,再次将线程创建成本增加到大约80us。

我并没有进入更大的范围,但是我可以清楚地看到有效负载时间与线程开销之间的关系(如上所述)。由于我无法解释这种行为,因此我认为一定有一个陷阱。我的时间测量可能不准确吗?还是基于较高或较低的负载,CPU Turbo是否会导致不同的时序?有人可以照亮吗?

这是我得到的时间的一个随机例子。数字代表上述图案。在许多不同的计算机硬件(各种Intel和AMD处理器)和Linux版本(Ubuntu 14.04、16.04、18.04,CentOS 6.9和CentOS 7.4)上都可以观察到相同的模式:

payload runtime      0.3 us., thread overhead  31.3 us.
payload runtime      0.6 us., thread overhead  32.3 us.
payload runtime      2.5 us., thread overhead  18.0 us.
payload runtime      1.9 us., thread overhead  21.2 us.
payload runtime      2.5 us., thread overhead  25.6 us.
payload runtime      5.2 us., thread overhead  21.4 us.
payload runtime      8.7 us., thread overhead  16.6 us.
payload runtime     18.5 us., thread overhead  17.6 us.
payload runtime     36.1 us., thread overhead  17.7 us.
payload runtime     73.4 us., thread overhead  22.2 us.
payload runtime    134.9 us., thread overhead  19.6 us.
payload runtime    272.6 us., thread overhead  44.8 us.
payload runtime    543.4 us., thread overhead  65.9 us.
payload runtime   1045.0 us., thread overhead  70.3 us.
payload runtime   2082.2 us., thread overhead  69.9 us.
payload runtime   4160.9 us., thread overhead  76.0 us.
payload runtime   8292.5 us., thread overhead  79.2 us.
payload runtime  16523.0 us., thread overhead  86.9 us.
payload runtime  33017.6 us., thread overhead  85.3 us.
payload runtime  66242.0 us., thread overhead  76.4 us.
payload runtime 132382.4 us., thread overhead  69.1 us.

2 个答案:

答案 0 :(得分:2)

我有一些假设。第一个:

您正在一个未加载的系统上运行此基准测试,但大概后台活动仍在发生。然后:

  • 主线程在CPU内核上声明,我将其称为内核1。
  • 主线程触发子线程以运行PayloadFunction
  • 那时,主线程仍在vThread构造函数/ syscall内部运行,因此子线程被安排在内核2上运行,这是免费的。
  • 稍后,主线程调用join并被挂起。
  • 子线程继续在核心2上运行,核心1仍然未被占用。

现在,如果有效负载运行时间不太高,则子级退出核心1的大部分时间仍然空闲。主线程唤醒,并由智能系统调度程序*在内核1上重新调度。

但是有时候,当核心1空闲时,随机后台任务会被唤醒并调度到该核心。然后,主线程再次唤醒,但是核心1仍然被占用。调度程序注意到核心2或系统中的某些其他核心是空闲的,并将主线程迁移到该核心。线程迁移是一个相对昂贵的操作。如果新内核处于睡眠状态,则需要向其发送处理器间中断(或者是内核间中断?)以将其唤醒。即使没有必要,主线程也至少会导致速度变慢,因为新内核上的缓存需要加载其数据。我希望新内核在大多数情况下都将成为内核2,因为它刚刚完成了其子线程,因此现在正在运行调度程序,该调度程序刚刚发现主线程可以再次运行。

1a:如果调度程序为它们最后运行的内核记住每个线程,并尝试调度线程在它们变为可运行状态时再次在同一内核上运行,那么这种情况仅取决于内核1被占用时的概率。主线程唤醒。该概率不应很大程度上取决于内核空闲的时间。但是,如果主线程仅挂起了很少的时间,则可能由于某些原因,系统没有机会将不同的任务调度到内核1。这在某种程度上与您获得的数据相对应,因为有效负载运行时间在270 µs左右似乎不连续。

1b:如果调度程序仅记住每个内核运行的最后一个线程,并且如果中间没有其他线程在该内核上运行,则仅尝试在同一内核上再次运行线程,那么我们可以预期主线程的概率要在内核1上唤醒的线程与线程休眠的时间线性相关。这样一来,每次循环迭代的平均成本就可以渐近地接近将线程迁移到另一个内核的延迟。

在您的测量中,我认为存在太多抖动,因此无法强烈赞成上述选择之一。

*我不完全确定Windows和Linux在上次运行的同一内核上调度线程的能力如何,但是一个快速的Google表示至少 some 个调度程序会尝试这样做那。 Here's的一篇文章描述了Linux调度程序的某些功能,我只是快速浏览了一下,但看起来很有趣。

假设二:

当一个cpu内核由于没有工作要做而进入睡眠状态时,它可能会保留最后一个在其上运行的进程的进程上下文。我希望它仅在发现有要运行的实际新流程时才切换流程上下文。如果核心被唤醒并发现它可以继续运行之前运行的相同任务,则可能会注意到它不需要更改上下文。

如果上述假设成立,那还意味着唤醒线程并继续运行所花费的时间取决于它是否是在内核上运行的最后一个线程,在这种情况下,它的上下文(包括例如内存映射,TLB缓存等),无需重新加载。主线程处于睡眠状态时安排其他事件的可能性与线程处于睡眠状态的时间成线​​性比例,因此这将显示类似于假设1b的行为。

测试方式

在上述所有假设下,我们可以预期某些行为:

  • 如果要测量每个线程创建/连接周期的操作,您会发现有一个快速和慢速的时间,或者可能是两个以上的时间,这与线程是否迁移相对应。可能存在一种方法来找出您当前在哪个物理内核上运行,因此您可以尝试直接进行验证。
  • 您测量的变慢不会发生,因为单个线程的创建/连接变得更加昂贵,而是因为您更频繁地采用慢变模式。
  • 您还可以尝试在具有cpu相似性设置的情况下运行程序,使其仅在一个内核上运行。这样,一个核心将始终被占用,其他进程将在其他核心上运行。这样就应该消除快速操作和慢速操作之间的差异。与当前的快速案例相比,您甚至可以获得加速,因为不需要内核间的通信来启动子线程。子线程启动时会有一点争用,但是主线程尚未将自己暂停在join中,但是我希望这比处理器间通信延迟要短。

您可以尝试通过进行抖动较小的测量来区分伪码1a和1b,并尝试找出开销的增加是否与两种方案的预期开销都匹配。我不确定您是否可以区分1b和2,但是您也可以尝试阅读调度程序。

答案 1 :(得分:0)

您可能正在时序说明的“错误”侧执行某些代码。避免这种情况的一种简单方法是调用特殊的x86指令CPUID。在GCC上,您可以这样操作:

#include <cpuid.h>

unsigned out[4];
__get_cpuid(1, &out[0], &out[1], &out[2], &out[3]);

在开始计时之前和停止计时之后放置这样的呼叫。它将充当“栅栏”,防止跨您的时序边界对操作进行重新排序。