内存复制基准测试的吞吐量分析

Throughput analysis on memory copy benchmark

我正在对以下 copy 函数(不是花哨的!)进行基准测试,其中包含一个高 size 参数 (~1GB):

void copy(unsigned char* dst, unsigned char* src, int count)
{
    for (int i = 0; i < count; ++i)
    { 
         dst[i] = src[i];
    }
}

我在 Xeon E5-2697 v2 上使用 GCC 6.2 和 -O3 -march=native -mtune-native 构建了这段代码。

为了让大家看看gcc生成的程序集在我的机器上,我把内循环生成的程序集贴在这里:

movzx ecx, byte ptr [rsi+rax*1]
mov byte ptr [rdi+rax*1], cl
add rax, 0x1
cmp rdx, rax
jnz 0xffffffffffffffea

现在,由于我的 LLC 约为 25MB,而我正在复制 ~1GB,因此这段代码受内存限制是有道理的。 perf 通过大量停滞的前端周期证实了这一点:

        6914888857      cycles                    #    2,994 GHz                    
        4846745064      stalled-cycles-frontend   #   70,09% frontend cycles idle   
   <not supported>      stalled-cycles-backend   
        8025064266      instructions              #    1,16  insns per cycle        
                                                  #    0,60  stalled cycles per insn

我的第一个问题是每条指令约有 0.60 个停滞周期。对于这样的代码来说,这似乎是一个非常低的数字,因为数据没有被缓存,所以一直访问 LLC/DRAM。由于 LLC 延迟为 30 个周期,主存储器约为 100 个周期,这是如何实现的?

我的第二个问题是相关的;似乎预取器做得相对不错(不足为奇,它是一个数组,但仍然如此):我们有 60% 的时间命中了 LLC 而不是 DRAM。不过,其他时候失败的原因是什么?哪个 bandwidth/part 的 uncore 导致这个预取器无法完成它的任务?

          83788617      LLC-loads                                                    [50,03%]
          50635539      LLC-load-misses           #   60,43% of all LL-cache hits    [50,04%]
          27288251      LLC-prefetches                                               [49,99%]
          24735951      LLC-prefetch-misses                                          [49,97%]

最后但同样重要的是:我知道英特尔可以流水线指令;这种带有内存操作数的mov也是这样吗?

非常感谢!

My first question is about 0.60 stalled cycles per instruction. This seems like a very low number for such code that access LLC/DRAM all the time as the data is not cached. As LLC latency is 30cycles and main memory around 100 cycles, how is this achieved?

My second question is related; it seems that the prefetcher is doing a relatively good job (not surprising, it's an array, but still): we hit 60% of the time the LLC instead of DRAM. Still, what is the reason for it to fail the other times? Which bandwidth/part of the uncore made this prefetcher fails to accomplish its task?

带预取器。具体来说,根据它是哪个 CPU,可能有一个“TLB 预取器”获取虚拟内存转换,加上一个将数据从 RAM 获取到 L3 的缓存行预取器,以及一个从 L3 获取数据的 L1 或 L2 预取器。

请注意,缓存(例如 L3)在物理地址上工作,其硬件预取器用于检测和预取对物理地址的顺序访问,并且由于虚拟内存 management/paging 物理访问“几乎从不”顺序在页面边界。由于这个原因,预取器在页面边界停止预取,并且可能需要三个“非预取”访问才能从下一页开始预取。

另请注意,如果 RAM 速度较慢(或代码速度较快),预取器将无法跟上,您会停顿更多。对于现代多核机器,RAM 的速度通常足以跟上一个 CPU,但无法跟上所有 CPU。这意味着在“受控测试条件”之外(例如,当用户同时 运行 50 个进程并且所有 CPU 都在冲击 RAM 时)你的基准将是完全错误的。还有 IRQ、任务切换和页面错误等 can/will 干扰(尤其是当计算机负载不足时)。

Last, but not least: I know that Intel can pipeline instructions; is it also the case for such mov with memory operands?

是;但是涉及内存的正常 mov(例如 mov byte ptr [rdi+rax*1], cl)也将受到“write ordered with store forwarding”内存排序规则的限制。

请注意,有很多方法可以加快复制速度,包括使用非临时存储(故意 break/bypass 内存排序规则),使用 rep movs(专门针对尽可能使用整个缓存行),使用更大的块(例如 AVX2 一次复制 32 个字节),自己进行预取(尤其是在页面边界),并进行缓存刷新(这样缓存在复制完成后仍然包含有用的东西)完成)。

然而,反其道而行之 - 故意使大型副本变得非常慢,这样程序员就会注意到它们很糟糕并且“被迫”试图找到避免进行复制的方法。它可以花费 0 个周期来避免复制 20 MiB,这比“最差”的替代方案快得多。

TL;DR: unfused域一共有5个uops(参见:Micro fusion and addressing modes). The loop stream detector on Ivy Bridge cannot allocate uops across the loop body boundaries (See: ),所以需要2个cycle来分配1次迭代。在双插槽 Xeon E5-2680 v2 上,循环实际上 运行s 在 2.3c/iter(每个插槽 10 个内核,而你的 12 个内核),因此这接近于前端瓶颈所能达到的最佳效果.

预取器表现非常好,大多数时候循环不受内存限制。每 2 个周期复制 1 个字节非常慢。 (gcc 做得不好,应该给你一个循环,可以 运行 每个时钟迭代 1 次。如果没有配置文件引导的优化,即使 -O3 也不会启用 -funroll-loops,但是它可能使用了一些技巧(比如将负索引向上计数为零,或者相对于存储索引负载并递增目标指针)可以将循环降低到 4 uops。)

每次迭代比前端瓶颈平均慢 0.3 个周期 可能来自预取失败时的停顿(可能在页面边界处),或者可能来自页面此测试中的错误和 TLB 未命中 运行 超过 .data 部分中的静态初始化内存。


循环中有两个数据依赖。首先,存储指令(特别是 STD uop)取决于加载指令的结果。其次,存储和加载指令都依赖于 add rax, 0x1。事实上,add rax, 0x1 也取决于它自己。由于 add rax, 0x1 的延迟是一个周期,因此循环性能的上限是每次迭代 1 个周期。

由于存储 (STD) 取决于负载,因此在负载完成之前无法从 RS 调度它,这至少需要 4 个周期(在 L1 命中的情况下)。此外,只有一个端口可以接受 STD 微指令,但在 Ivy Bridge 上每个周期最多可以完成两个负载(特别是在两个负载是驻留在 L1 缓存中的行并且没有发生组冲突的情况下),导致额外的争用。但是,RESOURCE_STALLS.ANY 表明 RS 实际值永远不会变满。 IDQ_UOPS_NOT_DELIVERED.CORE 计算未使用的问题槽数。这相当于所有广告位的 36%。 LSD.CYCLES_ACTIVE事件表明LSD大部分时间用于传递uops。然而,LSD.CYCLES_4_UOPS/LSD.CYCLES_ACTIVE =~ 50% 表明在大约 50% 的周期中,不到 4 微指令被传送到 RS。由于次优分配吞吐量,RS 不会变满。

stalled-cycles-frontend 计数对应于 UOPS_ISSUED.STALL_CYCLES,它计算由于前端停顿和后端停顿造成的分配停顿。我不明白 UOPS_ISSUED.STALL_CYCLES 与周期数和其他事件有什么关系。

LLC-loads 计数包括:

  • 所有需求负载 请求 到 L3,无论请求在 L3 中是命中还是未命中,如果未命中,也无论数据源如何。这还包括来自页面遍历硬件的按需加载请求。我不清楚是否计算来自下一页预取器的加载请求。
  • 由 L2 预取器生成的所有硬件预取数据读取请求,其中目标行将放置在 L3 中(即,在 L3 中或同时在 L3 和 L2 中,但不仅在 L2 中)。不包括仅将行放置在 L2 中的硬件 L2 预取器数据读取请求。请注意,L1 预取器的请求转到 L2 并影响并可能触发 L2 预取器,即它们不会跳过 L2。

LLC-load-missesLLC-loads 的子集,仅包括 L3 中遗漏的那些事件。两者都按核心计算。

计数请求(缓存行粒度)与计数加载指令或加载微指令(使用 MEM_LOAD_UOPS_RETIRED.*)之间存在重要区别。 L1 和 L2 缓存都将加载请求压缩到同一缓存行,因此 L1 中的多次未命中可能会导致对 L3 的单个请求。

如果所有存储和加载都命中 L1 缓存,则可以获得最佳性能。由于您使用的缓冲区大小为 1GB,因此该循环最多可导致 1GB/64 =~ 17M L3 demand load 请求。但是,您的 LLC-loads 测量值 83M 大得多,可能是由于问题中显示的循环以外的代码所致。另一个可能的原因是您忘记使用 :u 后缀来仅计算用户模式事件。

我对 IvB 和 HSW 的测量表明 LLC-loads:u 与 17M 相比可以忽略不计。然而,大多数 L3 负载都是未命中(即 LLC-loads:u =~ LLC-loads-misses:u)。 CYCLE_ACTIVITY.STALLS_LDM_PENDING 表明负载对性能的总体影响可以忽略不计。此外,我的测量显示 IvB 上的循环 运行s 为 2.3c/iter(与 HSW 上的 1.5c/iter 相比),这表明每 2 个周期发出一个负载。我认为次优分配吞吐量是造成这种情况的主要原因。请注意,4K 混叠条件 (LD_BLOCKS_PARTIAL.ADDRESS_ALIAS) 几乎不存在。所有这些都意味着预取器在隐藏大多数负载的内存访问延迟方面做得非常好。


IvB 上可用于评估硬件预取器性能的计数器:

您的处理器有两个 L1 数据预取器和两个 L2 数据预取器(其中一个可以将两者预取到 L2 and/or L3)。由于以下原因,预取器可能无效:

  • 未满足触发条件。这通常是因为尚未识别访问模式。
  • 已触发预取器,但预取到无用行。
  • 预取器已触发到有用的行,但该行在使用前已被替换。
  • 预取器已被触发到有用的行,但需求请求已到达缓存但未命中。这意味着发出需求请求的速度快于预取器及时做出反应的能力。这可能发生在你的情况下。
  • 预取器已被触发到一个有用的行(缓存中不存在),但该请求不得不被丢弃,因为没有 MSHR 可用于保存该请求。这可能发生在你的情况下。

L1、L2 和 L3 的需求未命中数是预取器执行情况的良好指标。所有的L3 misses(按LLC-load-misses统计)也必然是L2 misses,所以L2 misses的数量大于LLC-load-misses。此外,所有 L2 未命中的需求都必然是 L1 未命中。

在 Ivy Bridge 上,您可以使用 LOAD_HIT_PRE.HW_PFCYCLE_ACTIVITY.CYCLES_* 性能事件(除了未命中事件)来了解有关预取器如何执行的更多信息并评估它们对性能的影响。衡量 CYCLE_ACTIVITY.CYCLES_* 事件很重要,因为即使未命中次数看似很高,也不一定意味着未命中是性能下降的主要原因。

请注意,L1 预取器无法发出推测性 RFO 请求。因此,大多数到达 L1 的写入实际上会丢失,需要在 L1 和其他级别的每个缓存行分配一个 LFB。


我使用的代码如下。

BITS 64
DEFAULT REL

section .data
bufdest:    times COUNT db 1 
bufsrc:     times COUNT db 1

section .text
global _start
_start:
    lea rdi, [bufdest]
    lea rsi, [bufsrc]

    mov rdx, COUNT
    mov rax, 0

.loop:
    movzx ecx, byte [rsi+rax*1]
    mov byte [rdi+rax*1], cl
    add rax, 1
    cmp rdx, rax
    jnz .loop

    xor edi,edi
    mov eax,231
    syscall