【问题标题】:Is the time overhead of execution in a c++11 std::thread dependent on the payload executed?c++11 std::thread 中执行的时间开销是否取决于执行的有效负载?
【发布时间】:2019-03-07 14:34:48
【问题描述】:

我想知道与直接执行相比,在 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 微秒(us)的典型线程创建成本。一方面,与我的预期相比,这很便宜!

但现在出现了奇怪的部分:线程开销似乎取决于(非常可重现)在有效负载方法中花费的实际时间!这对我来说毫无意义。但它可以在六种不同的硬件机器上重现,这些机器具有各种风格的 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.

【问题讨论】:

  • vThread.join(); 等待线程终止,因此您通过在前一个线程通知主线程之前不启动新线程来序列化执行。要么分离它,要么创建一个线程数组。
  • 哦,我想你是故意的?但这意味着您还要对通知主线程的时间进行基准测试,而不仅仅是创建新线程的时间。
  • @PeterCordes 是的,有意立即加入线程。这个实现创建了线程的串行执行,我可以将负载中花费的净时间与外部时间进行比较,以查看线程创建的开销。增加通知主线程的时间也是有意的,因为我想要线程有效负载执行的总“成本开销”。这个问题不是重复的,我不想分离线程。
  • 您的标题说您只是尝试对线程 创建 时间进行微基准测试,但实际上您正在测试整个往返行程以生成单个工作线程并等待其结果,没有并行性。顶部的 TL:DR 总结可能是个好主意。
  • 您的 CPU 是否一直固定在 4GHz ?也许与perf stat 核对。也许禁用 turbo 并使用sudo sh -c 'for i in /sys/devices/system/cpu/cpufreq/policy[0-9]*/energy_performance_preference;do echo performance &gt; "$i";done' 将您的 EPP 设置为性能。可能不是这样。如果有的话,您会期望更长的工作负载来保持 CPU 以最大速度运行并减少开销。也许缓存会变冷,但额外的 60us 似乎太多了。

标签: linux multithreading performance c++11 benchmarking


【解决方案1】:

我有一些假设。第一个:

您正在一个未加载的系统上运行此基准测试,但可能仍有少量后台活动发生。那么:

  • 主线程在 CPU 内核上声明,我将其称为内核 1。
  • 主线程触发子线程运行PayloadFunction
  • 此时主线程仍在vThread 构造函数/系统调用中运行,因此子线程被安排在核心 2 上运行,这是免费的。
  • 稍后主线程调用join 并被挂起。
  • 子线程继续在核心 2 上运行,核心 1 保持空置状态。

现在,如果有效负载运行时间不是太高,那么在子退出核心 1 的大部分时间仍然是空闲的。主线程被唤醒并被智能系统调度程序再次调度到核心 1 上*。

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

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

1b:如果调度程序只记住每个内核运行的最后一个线程,并且仅在同一内核上没有任何其他运行时才尝试在同一内核上再次运行一个线程,那么我们可以预期主线程的概率要在核心 1 上唤醒的线程线性依赖于线程休眠的时间。然后可以看到每次循环迭代的平均成本逐渐接近将线程迁移到另一个内核的延迟。

在您的测量中,我认为抖动太大,无法强烈支持上述选项之一。

* 我不完全确定 Windows 和 Linux 在他们上次运行的同一内核上调度线程方面有多聪明,但快速的谷歌显示至少 一些 调度程序尝试这样做那。 Here's 一篇描述 Linux 调度程序所做的一些事情的文章,我只是快速浏览了一下,但似乎很有趣。

假设二:

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

如果上述假设成立,那也意味着线程被唤醒并继续运行所需的时间取决于它是否是在内核上运行的最后一个线程,在这种情况下,它的上下文(包括例如内存映射、TLB 缓存等)不需要重新加载。当主线程处于休眠状态时,其他任务被调度的概率与线程处于休眠状态的时间成线​​性比例,因此这将表现出类似于假设 1b 的行为。

测试方法

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

  • 如果您要测量每个单独的线程创建/加入周期的操作,您会看到有一个快速和一个缓慢的时间,或者可能超过两个级别,对应于线程是否被迁移。可能有一种方法可以找出您当前在哪个物理内核上运行,因此您可以尝试直接验证这一点。
  • 您衡量的减速并不是因为单个线程创建/连接变得更加昂贵,而是因为您更频繁地采用慢速模式。
  • 您也可以尝试使用 cpu 关联集运行您的程序,这样它就只能在一个内核上运行。然后,一个核心将始终被占用,其他进程将在其他核心上运行。这应该消除快速和慢速操作之间的差异。与当前的快速情况相比,您甚至可以获得加速,因为不需要内核间通信来启动子线程。在子线程已启动但主线程尚未在join 中暂停自身的时间会有一点争用,但我希望这会小于处理器间通信延迟。

您可以尝试区分假设 1a 和 1b,方法是进行具有较少抖动的测量,并尝试确定开销的增加是否与两种方案的预期开销相匹配。我不确定你能区分 1b 和 2,但你也可以尝试阅读调度程序。

【讨论】:

【解决方案2】:

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

#include <cpuid.h>

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

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

【讨论】:

  • 这可能会导致 224 微秒的完整无序执行窗口(Skylake ROB 大小 en.wikichip.org/wiki/intel/microarchitectures/…)。在大约 4GHz 的情况下,如果存在缓慢的高速缓存未命中,最坏的情况可能是每时钟 0.1 条指令,这可能会导致 224 * 0.1 IPC / (4 GHz) = 560 ns = 0.5 微秒,作为信封计算的粗略计算。 OP 正在看到 20 us 范围内的影响。
猜你喜欢
  • 2018-01-19
  • 2020-02-13
  • 1970-01-01
  • 2023-03-08
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2013-05-29
相关资源
最近更新 更多