为什么每次迭代的 uops 数量会随着流式负载的步幅而增加?

Why does the number of uops per iteration increase with the stride of streaming loads?

考虑以下循环:

.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 未命中。随着stride的增加,L1 miss和TLB miss的总数会增加,性能也会相应下降。

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

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

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

大步幅的两个明显效果是:

为了进一步调查,下图显示了来自微码辅助的微指令数。每次迭代的微代码辅助 uops 数量增加,直到它在步幅 4096 处达到最大值,就像其他性能事件一样。对于所有步幅,每个 4K 虚拟页面的微码辅助 uops 数为 506。 "Extra 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

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

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

例如,当您从步幅 0 进步到 4096 时,instructions 计数器从 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 微指令(额外 2 个)。

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

例如,我发现在 Haswell 和 Skylake2 上,当负载在 L1 中未命中但在 L2 中命中时,您会看到加载端口(端口 2 和端口 3)。大概发生的情况是 uop 在假设它将在 L1 中命中时被调度,并且当这没有发生时(结果在调度程序预期时尚未准备好),它会以新的时间重播,以预测 L2 命中。这是 "lightweight" 因为它不需要任何类型的管道清除,因为没有执行错误路径指令。

对于 L3 未命中,我观察到每次加载 3 微指令。

鉴于此,假设新页面上的未命中导致加载 uop 被重播两次(正如我所观察到的),并且这些 uops 出现在 MEM_UOPS_RETIRED 计数器中似乎是合理的。人们可能会合理地争辩说,重放的微指令并没有退役,但在某种意义上,退役更多地与指令相关,而不是微指令。也许这个计数器更好地描述为 "dispatched uops associated with retired load instructions".

UOPS_RETIRED.ALLIDQ.MS_UOPS

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

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

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

周期与 uop 导数

在问题的最后一部分,您表明 "slope" 周期与偏移量的斜率比退役微指令与偏移量的斜率大 2.6 倍。

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

你说:

If interrupts and page faults were indeed the (only) cause of perturbation, shouldn't both rates be very close?

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

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

过度计算的研究

任何对由于页面错误和其他事件导致的过度计数感兴趣的人都可能对 this github repository which has exhaustive tests for "determinism" of various PMU events, and where many results of this nature have been noted, including on Haswell. It doesn't however cover all the counters Hadi mentions here (otherwise we'd already have our answer). Here's the associated paper and some easier-to-consume 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 在许多情况下,这个 "inexactness" 仍然是 确定性的 - 因为过度计数或计数不足总是表现在存在外部事件的情况下以相同的方式进行,因此如果您还跟踪发生了多少相关事件,您也许能够纠正它。

2 我并不是要将其限制为这两个微架构:它们恰好是我测试过的。

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

看代码很容易看出执行单次迭代需要3微秒。前几次加载可能会在 L1 缓存中丢失,但随后的所有加载都将命中缓存,因为所有虚拟页面都映射到相同的物理页面,并且 Intel 处理器中的 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 产生相同的影响。请注意,这与每次迭代的页面错误数形成对比,后者对于不同的加载步幅是不同的。作为对访问的每个页面执行循环的直接结果而退役的 uops 数量是恒定的。我们的代码不会引发任何软件异常,因此我们不必担心它们。硬件中断呢?好吧,在 Linux 上,只要我们 运行 未分配给处理 mouse/keyboard 中断的内核上的代码,唯一真正重要的中断就是本地 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)

请注意,一般来说,我们不知道或不确定我们感兴趣的性能事件,这就是我们需要对其进行衡量的原因。退休的 uops 的情况很简单。但总的来说,这是我们需要通过实验找出或验证的。本质上,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

那么这是否适用于所有表演活动?好吧,让我们尝试一些不太明显的事情。考虑例如 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。由于页面错误会增加 1退休指令计数器的额外事件,两次迭代将测量为 9,而不是 8。也就是说,每次迭代 4.5。当我实际测量 2048 步长案例的退休指令计数时,它非常接近 4.5。在所有情况下,当我应用此方法静态预测每次迭代中测量的退役指令的值时,误差始终小于 1%。尽管有硬件中断,这还是非常准确的。我认为只要总执行时间小于 50 亿个核心周期,硬件中断就不会对退役指令计数器产生任何重大影响。 (我的每一个实验都花费了不超过 50 亿次循环,这就是原因。)但是如上所述,必须始终注意发生故障的次数。

正如我上面所讨论的,有许多性能计数器可以通过计算每页值来更正。另一方面,退役指令计数器可以通过考虑获得页面错误的迭代次数来纠正。 RESOURCE_STALLS.ANYUOPS_RETIRED.STALL_CYCLES 也许可以像退役的指令计数器一样更正,但我没有研究这两个。