【问题标题】:Why does the number of uops per iteration increase with the stride of streaming loads?为什么每次迭代的微指令数会随着流加载的步幅而增加?
【发布时间】:2019-03-02 19:09:25
【问题描述】:

考虑以下循环:

.loop:
    add     rsi, OFFSET    
    mov     eax, dword [rsi]
    dec     ebp
    jg .loop

其中OFFSET 是某个非负整数,rsi 包含指向bss 部分中定义的缓冲区的指针。这个循环是代码中唯一的循环。也就是说,它在循环之前没有被初始化或触摸。据推测,在 Linux 上,缓冲区的所有 4K 虚拟页面将按需映射到同一个物理页面。因此,缓冲区大小的唯一限制是虚拟页面的数量。因此,我们可以轻松地试验非常大的缓冲区。

循环由 4 条指令组成。在 Haswell 的融合和非融合域中,每条指令都被解码为单个 uop。 add rsi, OFFSET 的连续实例之间也存在循环携带的依赖关系。因此,在负载总是在 L1D 中命中的空闲条件下,循环应该以每次迭代大约 1 个周期执行。对于小的偏移量(步幅),这要归功于基于 IP 的 L1 流式预取器和 L2 流式预取器。但是,两个预取器都只能在 4K 页面内进行预取,并且 L1 预取器支持的最大步幅为 2K。因此,对于小步幅,每 4K 页面应该有大约 1 个 L1 未命中。随着步幅的增加,L1 未命中和 TLB 未命中的总数会增加,性能也会相应下降。

下图显示了步幅在 0 到 128 之间的各种有趣的性能计数器(每次迭代)。请注意,所有实验的迭代次数都是恒定的。只有缓冲区大小会更改以适应指定的步幅。此外,仅计算用户模式性能事件。

这里唯一奇怪的是退休的微指令的数量随着步伐的增加而增加。对于步幅 128,它从每次迭代 3 微秒(如预期)到 11 微秒。这是为什么呢?

如下图所示,步幅越大,事情就越奇怪。在此图中,步幅范围从 32 到 8192,增量为 32 字节。首先,退役指令的数量以 4096 字节的步幅从 4 线性增加到 5,之后它保持不变。加载 uops 的数量从 1 增加到 3,并且每次迭代的 L1D 加载命中数保持 1。对于我来说,只有 L1D 加载未命中的数量才有意义。

较大步幅的两个明显影响是:

  • 执行时间增加,因此会发生更多硬件中断。但是,我正在计算用户模式事件,因此中断不应干扰我的测量。我还用tasksetnice 重复了所有实验,得到了相同的结果。
  • 页面遍历和页面错误的数量增加。 (我已经验证了这一点,但为了简洁起见,我将省略这些图表。)页面错误由内核在内核模式下处理。根据this 的回答,页面遍历是使用专用硬件(在Haswell 上?)实现的。尽管答案所基于的链接已失效。

为了进一步调查,下图显示了微码辅助的微指令数。与其他性能事件一样,每次迭代的微码辅助 uops 数量会增加,直到在步幅 4096 处达到最大值。对于所有步幅,每个 4K 虚拟页面的微码辅助 uops 数量为 506。 “额外的 uops”线绘制了退役 uops 的数量减去 3(每次迭代的预期 uops 数量)。

图表显示,对于所有步幅,额外的微码数略大于微码辅助微码数的一半。我不知道这意味着什么,但它可能与页面跳转有关,并且可能是观察到的扰动的原因。

即使每次迭代的静态指令数量相同,为什么每次迭代的退役指令和微指令数量会随着步幅的增加而增加?哪里来的干扰?


下图绘制了每次迭代的周期数与不同步幅下每次迭代的退役微指令数。周期数的增加速度远快于退役的微指令数。通过使用线性回归,我发现:

cycles = 0.1773 * stride + 0.8521
uops = 0.0672 * stride + 2.9277

取两个函数的导数:

d(cycles)/d(stride) = 0.1773
d(uops)/d(stride) = 0.0672

这意味着每增加 1 个字节,周期数就会增加 0.1773,而退役的微指令数会增加 0.0672。如果中断和页面错误确实是(唯一的)扰动原因,那么这两种速率不应该非常接近吗?

【问题讨论】:

  • 是的,自 P6 以来,页面浏览使用专用硬件,而不是微编码的 uops。 @Bee 说 L1 错过了执行额外 uop 的“成本”,显然它们会被重放或其他东西。 AVX 512 improvements?.
  • 关于回放,对于您错过的每一级缓存,似乎还有一个 p23 uop。即,L1 中的命中为 1 uop,L2 中的命中为 2 微秒,L3 中的命中为 3 微秒(也许这就是它停止的地方)。我想也许发生的事情是调度程序总是乐观的:它不知道你会命中什么级别的缓存,所以它在每一次机会时都会唤醒依赖操作以获得最佳命中:4/ L1 为 5 个周期,L2 为 12 个周期,依此类推。所以每次你错过你都会得到一个额外的 uop。在其他情况下,您也会得到很多 uops,例如,如果 4 周期快速路径失败。
  • @BeeOnRope:我对 L3 感到惊讶,延迟取决于环形总线争用,因此调度程序很难预测预期结果的确切周期。如果它基于在实际准备好之前的一个周期收到传入数据的通知,则不会出现误报。 (或者,即使未命中也会有通知,因此性能计数器可以在检测到 l3 未命中时而不是在 DRAM 结果到达时计算 l3 命中与未命中?)
  • 在 Linux 上,当页面错误发生时,操作系统可能会更新页面表以获取其他“附近”页面(在我的系统上,额外的 15 个页面)(如果它们是常驻的)。这意味着页面错误在我的系统上减少了 16 倍,因为每个错误实际上增加了 16 个页面。这适用于文件支持的页面,但可能不适用于特殊的 bss(隐式映射零页面或类似的东西)。
  • @PeterCordes 和 Hadi - 关于重播内容的另一个更新 - 经过更多检查后,我发现了发生了什么:通常重播的是 dependent 操作,这就是为什么插入一些 ALU 操作会阻止我看到它(因为我没有在看 p0156 uops)。因此,基本上,当负载馈入负载时,只会重播负载,因为它是唯一的依赖操作。如果您之后有 ALU 操作,则将重放 ALU 操作。有时会重放多个微指令,包括非直接依赖的微指令,似乎会在加载的一个周期内执行的微指令被重放。

标签: assembly x86 cpu-architecture intel-pmu


【解决方案1】:

我认为@BeeOnRope 的回答完全回答了我的问题。我想根据@BeeOnRope 的回答及其下的 cmets 在此处添加一些其他详细信息。特别是,我将展示如何确定性能事件是否在每次迭代中针对所有负载步幅发生固定次数。

通过查看代码很容易看出执行一次迭代需要 3 微秒。最初的几次加载可能会在 L1 缓存中丢失,但随后所有的加载都将在缓存中命中,因为所有虚拟页面都映射到同一个物理页面,并且英特尔处理器中的 L1 被物理标记和索引。所以3微秒。现在考虑UOPS_RETIRED.ALL 性能事件,它在uop 退出时发生。我们希望看到3 * number of iterations 这样的事件。执行期间发生的硬件中断和页面错误需要微码辅助来处理,这可能会扰乱性能事件。因此,对于性能事件 X 的特定度量,每个计数事件的来源可以是:

  • 正在分析的代码指令。我们称之为 X1
  • Uops 用于引发由于正在分析的代码尝试访问内存而发生的页面错误。我们称之为 X2
  • 由于异步硬件中断或引发软件异常,Uops 用于调用中断处理程序。我们称之为 X3

因此,X = X1 +X2 + X3

由于代码很简单,我们可以通过静态分析确定X1 = 3。但是我们对X2和X3,每次迭代可能不是常数。我们可以使用UOPS_RETIRED.ALL 来测量 X。幸运的是,对于我们的代码,页面错误的数量遵循一个常规模式:每页访问恰好一个(可以使用perf 进行验证)。可以合理地假设引发每个页面错误都需要相同数量的工作,因此每次都会对 X 产生相同的影响。请注意,这与每次迭代的页面错误数相反,对于不同的加载步幅,这是不同的。作为执行每页访问的循环的直接结果而引退的微指令数是恒定的。我们的代码不会引发任何软件异常,因此我们不必担心它们。硬件中断怎么办?好吧,在 Linux 上,只要我们在未分配处理鼠标/键盘中断的内核上运行代码,唯一真正重要的中断就是本地 APIC 计时器。幸运的是,这种中断也经常发生。只要每页所用的时间量相同,定时器中断对X的影响就会是每页不变的。

我们可以将前面的等式简化为:

X = X1 + X4.

因此,对于所有加载步幅,

(每页 X) - (每页 X1) = (每页 X4) = 常数。

现在我将讨论为什么这很有用,并提供使用不同性能事件的示例。我们将需要以下指称:

ec = total number of performance events (measured)
np = total number of virtual memory mappings used = minor page faults + major page faults (measured)
exp = expected number of performance events per iteration *on average* (unknown)
iter = total number of iterations. (statically known)

请注意,一般来说,我们不知道或不确定我们感兴趣的性能事件,这就是我们需要对其进行衡量的原因。退休的微商的案例很容易。但总的来说,这是我们需要通过实验来发现或验证的。本质上,exp 是性能事件的计数ec,但不包括引发页面错误和中断的事件。

基于上述论证和假设,我们可以推导出以下等式:

C = (ec/np) - (exp*iter/np) = (ec - exp*iter)/np

这里有两个未知数:常量C 和我们感兴趣的值exp。所以我们需要两个方程来计算未知数。由于该等式适用于所有步幅,因此我们可以使用两个不同步幅的测量值:

C = (ec1 - exp*iter)/np1
C = (ec2 - exp*iter)/np2

我们可以找到exp:

(ec1 - exp*iter)/np1 = (ec2 - exp*iter)/np2
ec1*np2 - exp*iter*np2 = ec2*np1 - exp*iter*np1
ec1*np2 - ec2*np1 = exp*iter*np2 - exp*iter*np1
ec1*np2 - ec2*np1 = exp*iter*(np2 - np1)

因此,

exp = (ec1*np2 - ec2*np1)/(iter* (np2 - np1))

让我们将这个等式应用于UOPS_RETIRED.ALL

步幅1 = 32
iter = 1000 万
np1 = 1000 万 * 32 / 4096 = 78125
ec1 = 51410801

步幅2 = 64
iter = 1000 万
np2 = 1000 万 * 64 / 4096 = 156250
ec2 = 72883662

exp = (51410801*156250 - 72883662*78125)/(10m*(156250 - 78125))
= 2.99

不错!非常接近每次迭代预期的 3 个退役微指令。

C = (51410801 - 2.99*10m)/78125 = 275.3

我计算了C 的所有步幅。它不完全是一个常数,但所有步幅都是 275+-1。

exp对于其他性能事件可以类似推导出来:

MEM_LOAD_UOPS_RETIRED.L1_MISS:exp=0
MEM_LOAD_UOPS_RETIRED.L1_HIT:exp=1
MEM_UOPS_RETIRED.ALL_LOADS:exp=1
UOPS_RETIRED.RETIRE_SLOTS:exp=3 p>

那么这是否适用于所有表演活动?好吧,让我们尝试一些不太明显的东西。以RESOURCE_STALLS.ANY 为例,它测量任何原因的分配器停顿周期。仅通过查看代码很难判断exp 应该是多少。请注意,对于我们的代码,RESOURCE_STALLS.ROBRESOURCE_STALLS.RS 为零。只有RESOURCE_STALLS.ANY 在这里很重要。有了exp的方程和不同步幅的实验结果,我们可以计算出exp

步幅1 = 32
iter = 1000 万
np1 = 1000 万 * 32 / 4096 = 78125
ec1 = 9207261

步幅2 = 64
iter = 1000 万
np2 = 1000 万 * 64 / 4096 = 156250
ec2 = 16111308

exp = (9207261*156250 - 16111308*78125)/(10m*(156250 - 78125))
= 0.23

C = (9207261 - 0.23*10m)/78125 = 88.4

我计算了C 的所有步幅。嗯,它看起来并不固定。也许我们应该使用不同的步幅?尝试没有坏处。

步幅1 = 32
iter1 = 1000 万
np1 = 1000 万 * 32 / 4096 = 78125
ec1 = 9207261

步幅2 = 4096
iter2 = 100 万
np2 = 100 万 * 4096 / 4096 = 1m
ec2 = 102563371

exp = (9207261*1m - 102563371*78125)/(1m*1m - 10m*78125))
= 0.01

C = (9207261 - 0.23*10m)/78125 = 88.4

(请注意,这次我使用了不同数量的迭代只是为了表明您可以做到这一点。)

exp 得到了不同的值。我已经计算了所有步幅的C,但它看起来仍然不是恒定的,如下图所示。对于较小的步幅,它会显着变化,然后在 2048 年之后略有变化。这意味着每页存在固定数量的分配器停顿周期的一个或多个假设并不那么有效。换句话说,不同步幅的分配器停顿周期的标准偏差是显着的。

对于UOPS_RETIRED.STALL_CYCLES 性能事件,exp = -0.32,标准偏差也很显着。这意味着每页存在固定数量的已停用停顿周期的一个或多个假设并没有那么有效。


我开发了一种简单的方法来更正已测量的已停用指令数量。 每个触发的页面错误都会将一个额外的事件添加到已停用的指令计数器。例如,假设在某个固定次数的迭代(例如 2 次)后定期发生页面错误。也就是说,每两次迭代,触发故障。当步幅为 2048 时,问题中的代码会发生这种情况。由于我们预计每次迭代有 4 条指令退出,因此在发生页面错误之前预期退出指令的总数为 4*2 = 8。由于页面错误增加了一个对于已退役指令计数器的额外事件,两次迭代将测量为 9 而不是 8。即,每次迭代 4.5。当我实际测量 2048 步长案例的退役指令数时,它非常接近 4.5。在所有情况下,当我应用此方法静态预测每次迭代测量的退役指令的值时,误差始终小于 1%。尽管存在硬件中断,但这是非常准确的。我认为只要总执行时间少于 50 亿个核心周期,硬件中断不会对退役指令计数器产生任何重大影响。 (我的每个实验都不超过 50 亿次循环,这就是原因。)但是如上所述,必须始终注意发生的故障数量。

正如我上面所讨论的,有许多性能计数器可以通过计算每页值来纠正。另一方面,可以通过考虑迭代次数来纠正引退指令计数器以获得页面错误。 RESOURCE_STALLS.ANYUOPS_RETIRED.STALL_CYCLES 或许可以与退役指令计数器类似地纠正,但我没有调查这两个。

【讨论】:

    【解决方案2】:

    您在许多性能计数器中反复看到的效果,其中值线性增加,直到步幅 4096 之后它保持不变,如果您假设该效果纯粹是由于随着步幅的增加而增加的页面错误是完全有意义的。页面错误会影响观察到的值,因为many counters are not exact 存在中断、页面错误等。

    例如,以instructions 计数器为例,当您从步长 0 前进到 4096 时,该计数器从 4 变为 5。我们从 other sources 知道,Haswell 上的每个页面错误都会在用户模式下计算一条额外的指令(以及一条在内核模式下也有额外的)。

    因此,我们期望的指令数是循环中 4 条指令的基数,再加上基于每个循环发生多少页错误的指令的一部分。如果我们假设每个新的 4 KiB 页面都会导致页面错误,那么每次迭代的页面错误数为:

    MIN(OFFSET / 4096, 1)
    

    由于每个页面错误都会计算一条额外的指令,因此我们有预期的指令计数:

    4 + 1 * MIN(OFFSET / 4096, 1)
    

    这与您的图表完全一致。

    因此,同时为所有计数器解释了斜率图形的粗略形状:斜率仅取决于每个页面错误的过度计数量。那么剩下的唯一问题是为什么页面错误会以您确定的方式影响每个计数器。我们已经介绍了instructions,但让我们看看其他的:

    MEM_LOAD_UOPS.L1_MISS

    每页只有 1 次未命中,因为只有触及下一页的负载才会丢失任何内容(它需要出错)。我实际上并不同意 L1 预取器不会导致其他未命中:我认为如果您关闭预取器,您会得到相同的结果。我认为您不会再有 L1 未命中,因为相同的物理页面支持每个虚拟页面,并且一旦您添加了 TLB 条目,所有行都已经在 L1 中(第一次迭代将丢失 - 但我猜您正在进行多次迭代)。

    MEM_UOPS_RETIRED.ALL_LOADS

    这显示每个页面错误 3 uops(额外 2 个)。

    我不能 100% 确定这个事件在 uop 重放的情况下是如何工作的。它是否总是根据指令计算固定数量的微指令,例如,您在 Agner 的指令 -> 微指令表中看到的数字?或者它是否计算代表指令发送的实际微指令数?这通常是相同的,但是当它们在不同的缓存级别丢失时,加载重放它们的微指令。

    例如,我发现在 Haswell 和 Skylake2 上,当 L1 中的负载丢失但在 L2 中命中时,您会看到负载端口(端口 2 和端口 3)之间总共有 2 个微指令。据推测,发生的情况是 uop 是在假设它将在 L1 中命中的情况下分派的,并且当这没有发生时(当调度程序预期它时结果还没有准备好),它会以预计 L2 命中的新时间来重放。这是“轻量级”,因为它不需要任何类型的管道清除,因为没有执行错误路径指令。

    对于 L3 未命中类似,我观察到每次负载 3 微秒。

    鉴于此,假设新页面上的未命中会导致加载 uop 被重播两次(正如我所观察到的),并且这些 uops 出现在 MEM_UOPS_RETIRED 计数器中,这似乎是合理的。有人可能会合理地争辩说,重放的微指令没有退役,但在某种意义上,退役与指令的关联比微指令更重要。也许这个计数器更适合描述为“与退役加载指令相关的调度微指令”。

    UOPS_RETIRED.ALLIDQ.MS_UOPS

    剩下的怪事是与每个页面相关的大量微指令。这似乎完全有可能与页面错误机制有关。您可以尝试在 TLB 中遗漏的类似测试,但不会出现页面错误(确保页面已经填充,例如,使用 mmapMAP_POPULATE)。

    MS_UOPSUOPS_RETIRED 之间的区别似乎并不奇怪,因为某些微指令可能不会退役。也许他们也算在不同的域中(我忘记了 UOPS_RETIRED 是融合域还是非融合域)。

    在这种情况下,用户和内核模式计数之间也可能存在泄漏。

    循环与 uop 导数

    在问题的最后一部分中,您表明周期与偏移的“斜率”比退役 uops 与偏移的斜率大约 2.6 倍。

    与上面一样,这里的效果在 4096 处停止,我们再次预计这种效果完全是由于页面错误造成的。所以斜率的差异仅仅意味着页面错误的周期是 uops 的 2.6 倍。

    你说:

    如果中断和页面错误确实是(唯一的)扰动原因,那么这两种速率不应该非常接近吗?

    我不明白为什么。微指令和周期之间的关系可能相差很大,可能相差三个数量级:CPU 可能每个周期执行四个微指令,或者执行单个微指令可能需要 100 秒的周期(例如缓存缺失加载)。

    每个 uop 2.6 个周期的值正好在这个大范围的中间,我并不觉得奇怪:它有点高(如果您在谈论优化的应用程序代码,“效率低下”)但我们在这里谈论页面错误处理是完全不同的事情,所以我们预计会有很长的延迟。

    关于过度计数的研究

    任何因页面错误和其他事件而对过度计数感兴趣的人可能会对this github repository 感兴趣,该this github repository 对各种 PMU 事件的“确定性”进行了详尽的测试,并且已经注意到许多这种性质的结果,包括哈斯韦尔。然而,它并没有涵盖哈迪在这里提到的所有计数器(否则我们已经有了答案)。 Here's the associated paper 和一些更易于使用的 associated slides - 他们特别提到每个页面错误都会产生一条额外的指令。

    这是对结果的引用from Intel

    Conclusions on the event determinism:
    1.  BR_INST_RETIRED.ALL (0x04C4)
    a.  Near branch (no code segment change): Vince tested 
        BR_INST_RETIRED.CONDITIONAL and concluded it as deterministic. 
        We verified that this applies to the near branch event by using 
        BR_INST_RETIRED.ALL - BR_INST_RETIRED.FAR_BRANCHES.
    b.  Far branch (with code segment change): BR_INST_RETIRED.FAR_BRANCHES 
        counts interrupts and page-faults. In particular, for all ring 
        (OS and user) levels the event counts 2 for each interrupt or 
        page-fault, which occurs on interrupt/fault entry and exit (IRET).
        For Ring 3 (user) level,  the counter counts 1 for the interrupt/fault
        exit. Subtracting the interrupts and faults (PerfMon event 0x01cb and
        Linux Perf event - faults), BR_INST_RETIRED.FAR_BRANCHES remains a 
        constant of 2 for all the 17 tests by Perf (the 2 count appears coming
        from the Linux Perf for counter enabling and disabling). 
    Consequently, BR_INST_RETIRED.FAR_BRANCHES is deterministic. 
    

    因此,您希望每个页面错误都有一条额外的指令(特别是分支指令)。


    1 在许多情况下,这种“不精确性”仍然是确定性的 - 因为在外部存在的情况下,过度计数或计数不足总是以相同的方式表现事件,因此如果您还跟踪发生了多少相关事件,您也许可以更正它。

    2我的意思不是把它限制在这两种微架构上:它们恰好是我测试过的那种。

    【讨论】:

    • 我熟悉 Weaver 的出色工作。表 6 提到指令计数可能会受到中断和页面错误的干扰。表 7 似乎表明 Haswell 上退役的微指令数量是相当确定的。第 3.1.2 节提到微码 uops 也可能计入退役的 uops。我的实验表明,每页微码 uops 的数量对于所有步幅都是恒定的,但每页退役的 uops 数量仅在步幅 4096 处变得恒定。我已经编辑了我的问题。彼得说页面遍历不需要微码微码,但我觉得这并不精确。
    • 关于 L1 预取器的要点。但是我们不应该只错过一次,或者也许很少错过(也就是说,与步幅没有相关性)?
    • @HadiBrais - 您的测试反映了与每个页面错误相关的大量微编码 uops 和一般的 uops,这不足为奇。这些数量是每页不变的(这意味着随着偏移量不断增加,直到 4096)。每页退休的微指令数量明显随着步幅而减少,因为较小的偏移量意味着每页有更多的迭代。我错过了什么吗?我认为跨步的事情可能会导致混乱:所有的图表看起来都很容易解释为每次迭代的 X 工作和每个页面错误的 Y 工作。
    • @HadiBrais - 当然 L1 未命中与“步幅相关”,因为步幅与页面错误的数量呈线性相关,并且未命中来自 TLB 未命中或页面错误。我再次认为整个步幅的事情令人困惑:如果您在从实际迭代中减去“预期值”(我最后评论中的 X)之后“每页”绘制所有内容,那么一切都会是平的。额外的 uops 不是来自额外的“步幅”,而是来自所有页面错误,由于测试的设计,这些页面错误与步幅成正比。
    • 最后我在电子表格中发现了一个错误。我计算的是(uops per page - (3*instructions per page)) 而不是(uops per page - (3*iterations per page))。现在,所有步幅的 uop 计数都保持在 274 :) 。现在考虑(instructions per page - (4*iterations per page))。它在步幅 512 处迅速变平。在步幅 32 处为 0.26,然后增加,直到步幅为 512 及以后达到 1。
    猜你喜欢
    • 2014-01-08
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-12-13
    • 2021-11-22
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多