我想知道与直接执行相比,在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风格的硬件机器上!
PayloadFunction
内部花费1到100us,则典型的线程创建成本约为20us。PayloadFunction
中花费的时间增加到100-1000us时,线程创建成本将增加到40us左右。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.
答案 0 :(得分:2)
您正在一个未加载的系统上运行此基准测试,但大概后台活动仍在发生。然后:
PayloadFunction
。vThread
构造函数/ syscall内部运行,因此子线程被安排在内核2上运行,这是免费的。join
并被挂起。现在,如果有效负载运行时间不太高,则子级退出核心1的大部分时间仍然空闲。主线程唤醒,并由智能系统调度程序*在内核1上重新调度。
但是有时候,当核心1空闲时,随机后台任务会被唤醒并调度到该核心。然后,主线程再次唤醒,但是核心1仍然被占用。调度程序注意到核心2或系统中的某些其他核心是空闲的,并将主线程迁移到该核心。线程迁移是一个相对昂贵的操作。如果新内核处于睡眠状态,则需要向其发送处理器间中断(或者是内核间中断?)以将其唤醒。即使没有必要,主线程也至少会导致速度变慢,因为新内核上的缓存需要加载其数据。我希望新内核在大多数情况下都将成为内核2,因为它刚刚完成了其子线程,因此现在正在运行调度程序,该调度程序刚刚发现主线程可以再次运行。
1a:如果调度程序为它们最后运行的内核记住每个线程,并尝试调度线程在它们变为可运行状态时再次在同一内核上运行,那么这种情况仅取决于内核1被占用时的概率。主线程唤醒。该概率不应很大程度上取决于内核空闲的时间。但是,如果主线程仅挂起了很少的时间,则可能由于某些原因,系统没有机会将不同的任务调度到内核1。这在某种程度上与您获得的数据相对应,因为有效负载运行时间在270 µs左右似乎不连续。
1b:如果调度程序仅记住每个内核运行的最后一个线程,并且如果中间没有其他线程在该内核上运行,则仅尝试在同一内核上再次运行线程,那么我们可以预期主线程的概率要在内核1上唤醒的线程与线程休眠的时间线性相关。这样一来,每次循环迭代的平均成本就可以渐近地接近将线程迁移到另一个内核的延迟。
在您的测量中,我认为存在太多抖动,因此无法强烈赞成上述选择之一。
*我不完全确定Windows和Linux在上次运行的同一内核上调度线程的能力如何,但是一个快速的Google表示至少 some 个调度程序会尝试这样做那。 Here's的一篇文章描述了Linux调度程序的某些功能,我只是快速浏览了一下,但看起来很有趣。
当一个cpu内核由于没有工作要做而进入睡眠状态时,它可能会保留最后一个在其上运行的进程的进程上下文。我希望它仅在发现有要运行的实际新流程时才切换流程上下文。如果核心被唤醒并发现它可以继续运行之前运行的相同任务,则可能会注意到它不需要更改上下文。
如果上述假设成立,那还意味着唤醒线程并继续运行所花费的时间取决于它是否是在内核上运行的最后一个线程,在这种情况下,它的上下文(包括例如内存映射,TLB缓存等),无需重新加载。主线程处于睡眠状态时安排其他事件的可能性与线程处于睡眠状态的时间成线性比例,因此这将显示类似于假设1b的行为。
在上述所有假设下,我们可以预期某些行为:
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]);
在开始计时之前和停止计时之后放置这样的呼叫。它将充当“栅栏”,防止跨您的时序边界对操作进行重新排序。