【问题标题】:Why do I see 400x outlier timings when calling clock_gettime repeatedly?为什么我在重复调用clock_gettime 时看到400x 异常时间?
【发布时间】:2018-08-18 10:28:25
【问题描述】:

我正在尝试使用物理时钟来测量 c++ 中某些命令的执行时间,但我遇到了一个问题,即从计算机上的物理时钟读取测量值的过程可能需要很长时间。代码如下:

#include <string>
#include <cstdlib>
#include <iostream>
#include <math.h>
#include <time.h>

int main()
{
      int64_t mtime, mtime2, m_TSsum, m_TSssum, m_TSnum, m_TSmax;
      struct timespec t0;
      struct timespec t1;
      int i,j;
      for(j=0;j<10;j++){
      m_TSnum=0;m_TSsum=0; m_TSssum=0; m_TSmax=0;
      for( i=0; i<10000000; i++) {
            clock_gettime(CLOCK_REALTIME,&t0);
            clock_gettime(CLOCK_REALTIME,&t1);
            mtime = (t0.tv_sec * 1000000000LL + t0.tv_nsec);
            mtime2= (t1.tv_sec * 1000000000LL + t1.tv_nsec);

            m_TSsum += (mtime2-mtime);
            m_TSssum += (mtime2-mtime)*(mtime2-mtime);
            if( (mtime2-mtime)> m_TSmax ) { m_TSmax = (mtime2-mtime);}
            m_TSnum++;
      }
      std::cout << "Average "<< (double)(m_TSsum)/m_TSnum
            << " +/- " << floor(sqrt( (m_TSssum/m_TSnum  - ( m_TSsum/m_TSnum ) *( m_TSsum/m_TSnum ) ) ) )
            << " ("<< m_TSmax <<")" <<std::endl;
      }
}

接下来我在专用核心上运行它(或者系统管理员告诉我的),以避免进程被调度程序移动到后台的任何问题:

$ taskset -c 20 ./a.out

这是我得到的结果:

Average 18.0864 +/- 10 (17821)
Average 18.0807 +/- 8 (9116)
Average 18.0802 +/- 8 (8107)
Average 18.078 +/- 6 (7135)
Average 18.0834 +/- 9 (21240)
Average 18.0827 +/- 8 (7900)
Average 18.0822 +/- 8 (9079)
Average 18.086 +/- 8 (8840)
Average 18.0771 +/- 6 (5992)
Average 18.0894 +/- 10 (15625)

很明显,调用clock_gettime() 需要大约 18 纳秒(在此特定服务器上),但我无法理解为什么“最大”时间似乎要长 300 到 1000 倍?

如果我们假设核心真正专用于这个进程并且没有被其他东西使用(这可能是也可能不是真的;当不在专用核心上运行时,平均时间是相同的,但 sd/max 是更大一些),还有什么可能导致这些“减速”(因为没有更好的名字)?

【问题讨论】:

  • 如果您可以访问 C++11,您可能需要考虑使用 &lt;chrono&gt; insteaf og time.h
  • 探索std::chrono
  • 阅读(并使用)std::chrono
  • 专用内核并不意味着同一个内核不处理操作系统中断。对于纳秒精度,您需要查看 RTOS。
  • std::chrono 不会变魔术——在幕后它只会委托给clock_gettime 或其他类似的电话。

标签: c++ linux performance x86 clock


【解决方案1】:

这在现代 c++ 中要容易得多

#include <chrono>
auto start = std::chrono::steady_clock::now();
.....
auto stop = std::chrono::steady_clock::now();
auto duration = stop - start;

18 纳秒对于非实时操作系统来说是相当快的。你真的需要比这更准确地测量一些东西吗?根据我的计算,18ns 在 4GHz CPU 上只有 72 个时钟周期。

【讨论】:

  • 我不认为作者抱怨平均 18 纳秒。我认为最大 21 微秒是这里出乎意料的(不是真的)。实际上std::chrono 很可能在内部使用clock_gettime(在基于UNIX 的系统上),所以它不会有任何不同。但是std::chrono::steady_clock 可能会使用CLOCK_MONOTONIC,这比作者选择的CLOCK_REALTIME(可能在std::chrono::system_clock 中使用)要好。
【解决方案2】:

taskset 命令定义了您的进程的亲和性,这意味着您的进程被限制在指定的 CPU 内核上运行。它不会以任何方式限制其他进程,这意味着它们中的任何一个都可以随时抢占您的进程(因为它们都可以在您为进程选择的 CPU 内核上运行)。因此,您的最大读取时间间隔(即 5-25 微秒)可能代表您的 CPU 上的其他进程或中断运行时间加上上下文切换时间。除了您使用CLOCK_REALTIME,它可能会受到NTP 更正等影响。要测量时间间隔,您应该使用CLOCK_MONOTONIC(或特定于linux 的CLOCK_MONOTONIC_RAW)。

【讨论】:

  • 谢谢谢尔盖。我已经尝试过 CLOCK_MONOTONIC 和所有其他变体,结果是相同的(因为它们与 rtdsc 和 std::chrono 一样。内核调度程序的设置方式是我使用的特定内核永远不会分配给任何进程(除非你手动分配它使用任务集或类似的东西),所以如果系统管理员告诉我的真的是真的,这个核心不应该尝试切换到不同的进程......
  • @Bojan 即使管理员配置了调度程序,使其从系统一开始就不会默认包含您的 CPU 内核用于新进程(这看起来不太可能但可能是真的)时间读取机制本身仍然有可能需要每个 CPU 定时器 (rdtsc) 与一些硬件时钟定期同步以更新校正参数 - 这意味着在每个内核上接收周期性定时器中断。
【解决方案3】:

为什么是异常值?

当您在两次 clock_gettime 调用中迭代 1000 万次时,您可能会看到异常事件(和非异常变化),这与软件和硬件相关的原因有很多。这些原因包括:

  • 上下文切换:调度程序可能决定在 CPU 之间迁移您的进程,即使您将进程固定到 CPU,操作系统也可能会定期决定在您的逻辑 CPU 上运行其他东西
  • SMT:假设这是在具有 SMT 的 CPU 上(例如,x86 上的超线程),调度程序可能会定期在兄弟内核(与您的进程相同的物理内核)上调度某些内容。这会极大地影响代码的整体性能,因为两个线程正在竞争相同的核心资源。此外,在 SMT 和非 SMT 执行之间可能存在一个过渡期,在此期间什么都不执行,因为在 SMT 执行开始时内核必须重新分配一些资源。
  • 中断:典型系统每秒至少会收到数百个中断,来自网卡、图形设备、硬件时钟、系统定时器、音频设备、IO 设备、跨 CPU IPI 等。试试watch -n1 cat /proc/interrupts,看看你可能认为是空闲系统的操作是如何发生的。
  • 硬件暂停:CPU 本身可能会因各种原因(例如电源或热节流)或仅仅因为CPU is undergoing a frequency transition 而定期停止执行指令。
  • System Management Mode:除了操作系统看到和处理的中断之外,x86 CPU 有一种“隐藏中断”,它允许 SMM 功能在你的 CPU 上执行,唯一明显的影响是用于测量的周期计数器中的周期性意外跳转实时。
  • 正常的性能变化:您的代码不会每次都以完全相同的方式执行。初始迭代将遭受数据和指令缓存未命中,并且对于分支方向等事情具有未经训练的预测器。即使处于明显的“稳定状态”,您仍可能会因无法控制的事情而遭受性能变化。
  • 不同的代码路径:您可能希望循环每次通过1 执行完全相同的指令:毕竟,没有什么真正改变,对吧?好吧,如果您深入了解 clock_gettime 的内部结构,您很可能会发现某些分支在发生某些溢出时采用不同的路径,或者在读取 VDSO 比赛中的调整因子并进行更新等时。

这甚至不是一个完整的列表,但它至少应该让您了解一些可能导致异常值的因素。您可以消除或减少其中一些的影响,但在 x86 上的现代非实时2操作系统上通常不可能完全控制。

我的猜测

如果我必须根据 ~8000 ns 的典型异常值进行猜测,这对于上下文切换中断来说可能太小了,您可能会看到处理器频率缩放的效果,因为可变涡轮增压比。这是一个拗口,但基本上现代 x86 芯片以不同的“最大涡轮”速度运行,具体取决于有多少内核处于活动状态。例如,我的 i7-6700HQ 如果一个内核处于活动状态,则运行频率为 3.5 GHz,但如果 2、3 或 4 个内核处于活动状态,则分别只有 3.3、3.2 或 3.1 GHz。

这意味着即使您的进程从不中断,任何在另一个 CPU 上短暂运行的工作都可能导致频率转换(例如,因为您从 1 个活动核心转换到 2 个活动核心) ,并且在这种转换过程中,CPU 在电压稳定的情况下空闲了数千个周期。您可以找到一些详细的数字和测试in this answer,但结果是在测试的 CPU 上,稳定需要大约 20,000 个周期,非常符合您观察到的约 8000 纳秒的异常值。有时您可能会在一段时间内进行两次转换,从而使影响加倍,依此类推。

缩小范围

获取分布

如果您仍然想知道异常值的原因,您可以采取以下步骤并观察对异常值行为的影响。

首先,您应该收集更多数据。与其只记录超过 10,000,000 次迭代的最大值,您还应该收集一个具有一些合理桶大小(比如 100 ns,或者甚至更好的某种几何桶大小,可以在更短的时间内提供更高的分辨率)的直方图。这将是一个巨大的帮助,因为您将能够准确地看到时间聚集的位置:除了您用“max”记下的 6000 - 17000 ns 异常值之外,您完全有可能有其他影响,它们可以有不同的原因。

直方图还可以让您了解异常频率,您可以将其与您可以测量的事物的频率相关联,以查看它们是否匹配。

现在添加直方图代码还可能会增加时序循环的差异,因为(例如)您将根据时序值访问不同的缓存行,但这是可以管理的,特别是因为时间记录发生在“定时区域”之外。

问题具体缓解措施

有了这些,您可以尝试系统地检查我上面提到的问题,看看它们是否是原因。以下是一些想法:

  1. 超线程:只需在运行单线程基准测试时在 BIOS 中将其关闭,即可一次性消除所有问题。总的来说,我发现这也会导致细粒度基准方差大幅减少,因此这是一个很好的第一步。

  2. 频率缩放:在 Linux 上,您通常可以通过将性能调控器设置为“性能”来禁用次标称频率缩放。如果您使用的是intel_pstate 驱动程序,您可以通过将/sys/devices/system/cpu/intel_pstate/no_turbo 设置为0 来禁用超标称(又名turbo)。如果您有另一个驱动程序,您也可以操作涡轮模式directly via MSR,或者如果其他所有方法都失败了,您可以在 BIOS 中进行操作。在linked question 中,当涡轮被禁用时,异常值基本上消失了,所以首先要尝试一下。

    假设您确实想在生产中继续使用 Turbo,您可以手动将最大 turbo 比率限制为适用于 N 个内核(例如,2 个内核)的某个值,然后使其他 CPU 离线,因此最多可以使用该数量核心将永远处于活动状态。然后,无论有多少内核处于活动状态,您都可以始终以新的最大涡轮增压运行(当然,在某些情况下,您可能仍会受到功率、电流或热限制)。

  3. 中断:您可以搜索“中断亲和性”以尝试将中断移入/移出固定内核,并查看对异常值分布的影响。您还可以计算中断的数量(例如,通过/proc/interrupts)并查看计数是否足以解释异常值计数。如果你发现定时器中断是具体的原因,你可以探索你的内核提供的各种“tickless”(又名“NOHZ”)模式来减少或消除它们。您还可以通过 x86 上的HW_INTERRUPTS.RECEIVED 性能计数器直接计算它们。

  4. 上下文切换:您可以使用实时优先级或isolcpus 来防止其他进程在您的 CPU 上运行。请记住,上下文切换问题虽然通常被定位为主要/唯一问题,但实际上相当罕见:它们通常以HZ 速率发生(在现代内核上通常为 250/秒) - 但在一个大部分空闲的系统,调度程序实际上会决定在繁忙的 CPU 上调度另一个进程。如果您缩短基准循环,通常几乎可以完全避免上下文切换。

  5. 与代码相关的性能变化:您可以使用各种分析工具(例如 perf)检查是否发生这种情况。您可以仔细设计数据包处理代码的核心,以避免缓存未命中等异常事件,例如,通过预先接触缓存行,您可以尽可能避免使用复杂度未知的系统调用。

虽然上述某些内容纯粹用于调查目的,但其中许多都可以帮助您确定导致暂停的原因并减轻它们的影响。

我不知道所有问题的缓解措施 - 您可能需要专门的硬件或 BIOS 来避免像 SMM 这样的问题。


1 好吧,除非在if( (mtime2-mtime)&gt; m_TSmax ) 条件被触发的情况下——但这应该很少见(也许你的编译器已经让它无分支,在这种情况下只有一个执行路径)。

2实际上还不清楚即使使用硬实时操作系统也能达到“零方差”:某些 x86 特定因素(如 SMM 模式和 DVFS 相关的停顿)似乎是不可避免的。

【讨论】:

  • 感谢@BeeOnRope 的详细解释。我将添加一些代码将时间放入桶中并从中绘制直方图。希望这将进一步阐明这个问题。我真的不在乎我是否得到“零方差”;只要最坏的情况是合理的(比如在 100 纳秒以下),我或多或少都会感到满意。
  • 这一切始于我试图弄清楚为什么我会看到从多播 UDP 馈送中丢弃的数据包;偶尔会有大约每秒 400000 次的数据突发,这意味着我必须在 2.5 微秒内处理它们以避免数据在缓冲区中排队。通过测量时间和稍微优化代码,我将平均时间减少到 1 微秒以下,但我仍然会不时看到数据包丢失,我正试图找出导致它的原因......
  • 非常详细和中肯的解释。 +1。 @Bojan 我个人认为将 100 ns 视为最坏情况的延迟是不合理的(尤其是在非实时操作系统上)。在设计算法时最好避免这种假设。缓冲区中的数据排队有什么问题? (为什么你需要避免它?)
  • @Bojan - 100 ns 的最坏情况响应时间将很难实现,并且可能需要专门的硬件和软件(例如,用户模式网络堆栈)。考虑到 DRAM 的一次未命中通常在 100 ns 的范围内,并且使用 Meltdown 和 Spectre 补丁,单个内核调用可能是 300:因此,如果您需要每个数据包的用户内核转换,您将永远无法达到最后期限.排队是有原因的——它很明显“你想避免它”——排队数据包不仅可以帮助你避免在你看到的小停顿时丢包......
  • ... 但也经常使整个处理管道更高效,因为您可以批量处理事情,减少用户 - 内核转换,摊销各种操作的成本等。所以你真正需要的是至少平均处理时间为 2.5 us,而且还可以描述暂停并查看您的缓冲区/队列是否足够大以避免打嗝。根据我上面的列表,许多打嗝的来源也可以消除或减少。
猜你喜欢
  • 2018-12-01
  • 2016-03-16
  • 1970-01-01
  • 1970-01-01
  • 2012-06-05
  • 1970-01-01
  • 2017-12-23
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多