充分利用卡比湖上的管道

Fully utilizing pipelines on kaby lake

(后续代码审查 question here,包含此循环上下文的更多详细信息。)


环境:

我写的汇编代码不多,当我写的时候,它要么足够短,要么足够简单,我不必太担心从中挤出最大的性能。我更复杂的代码通常是用 C 编写的,我让编译器的优化器担心延迟、代码对齐等问题。

但是在我当前的项目中,MSVC 的优化器在我的关键路径中的代码上做得非常糟糕。所以...

我还没有找到一个好的工具来对 x64 汇编程序代码进行静态或运行时分析,以消除停顿、改善延迟等。我所拥有的只是 VS 分析器,它告诉我(大致)哪些指令花费的时间最多。墙上的时钟告诉我最近的变化是让事情变得更好还是更糟。

作为替代方案,我一直在努力阅读 Agner 的文档,希望能从我的代码中获得更多性能。问题是,除非你完全理解他的作品,否则很难理解他的任何作品。但它的一些部分是有道理的,我正在尝试应用我所学到的东西。

考虑到这一点,这是我最内层循环的核心(毫不奇怪),VS 探查器说我的时间正在花费:

nottop:

vpminub ymm2, ymm2, ymm3 ; reset out of range values
vpsubb  ymm2, ymm2, ymm0 ; take a step

top:
vptest  ymm2, ymm1       ; check for out of range values
jnz nottop

; Outer loop that does some math, does a "vpsubb ymm2, ymm2, ymm0",
; and eventually jumps back to top

是的,这几乎是依赖链的教科书示例:这个紧密的小循环中的每条指令都取决于前一个操作的结果。这意味着没有并行性,这意味着我没有充分利用处理器。

受到 Agner 的 "optimizing assembler" 文档的启发,我提出了一种方法(希望)允许我一次执行 2 个操作,这样我就可以让一个管道更新 ymm2 和另一个更新(比如) ymm8.

虽然这是一个重要的改变,所以在我开始拆开所有东西之前,我想知道它是否有帮助。查看 Agner 的 "Instruction tables" for kaby lake(我的目标),我看到:

        uops
        each
        port    Latency
pminub  p01     1
psubb   p015    1
ptest   p0 p5   3

鉴于此,看起来当一个管道使用 p0+p5 对 ymm2 执行 vptest 时,另一个可以利用 p1 在 ymm8 上执行 vpminub 和 vpsubb。是的,事情仍然会堆积在 vptest 后面,但它应该有所帮助。

还是会?

我目前 运行 此代码来自 8 个线程(是的,8 个线程确实给我带来了比 4、5、6 或 7 更好的总吞吐量)。鉴于我的 i7700k 有 4 个超线程内核,每个内核上有 2 个线程 运行 的事实是否意味着我 已经 最大化端口?端口 "per core," 不是 "per logical cpu," 对吗?

所以。

根据我目前对 Agner 工作的理解,似乎没有办法进一步优化当前形式的代码。如果我想要更好的性能,我将需要想出一个不同的方法。

是的,我确定如果我在这里发布我的整个 asm 例程,有人会建议另一种方法。但是这个问题的目的不是要有人为我编写代码。我想看看我是否开始了解如何考虑优化 asm 代码。

这是(大致)看待事物的正确方式吗?我错过了几件吗?或者这是完全错误的?

部分答案:

Intel 提供了一个名为 Intel Architecture Code Analyzer (described here) 的工具,它可以对代码进行静态分析,显示(某种)在一段 asm 代码中使用了哪些端口。

不幸的是:

  • v2.3 不包括基本的(也是唯一的)header 文件。您可以在 v2.2 中找到此文件。
  • v2.2 包含 header,但省略了用于分析输出的 python 脚本 (pt.py)。这个文件也没有包含在v2.3中(还没找到)。
  • iaca 的一种输出格式是 .dot 文件,由 graphviz 读取,但英特尔文档未能描述 graphviz 中 38 个可执行文件中的哪一个用于显示输出。

但也许最重要的是(满足我的需要):

  • v2.3(当前最新版本)支持 Skylake,但不支持 Kaby Lake。

鉴于处理器之间的实现细节如何变化,这使得所有输出都值得怀疑。 pdf 文件中的日期表明 v2.3 于 2017 年 7 月发布,这意味着我可能需要等待下一个版本。

TL:DR:我认为超线程应该让所有向量 ALU 端口忙于每个内核 2 个线程。


vptest 不写任何向量寄存器,只写标志。下一次迭代不必等待它,因此它的延迟几乎无关紧要。

只有jnz依赖vptest,推测执行+分支预测隐藏了控制依赖的延迟。 vptest 延迟与检测到分支预测错误的速度有关,但与正确预测情况下的吞吐量无关。


关于超线程的要点。在单个线程中交错两个独立的 dep 链可能会有所帮助,但要正确有效地做到这一点要困难得多。

让我们看看循环中的指令。 predicted-taken jnz 在 p6 上总是 运行,所以我们可以打折。 (展开实际上可能会造成伤害:predicted-not-taken jnz 也可以 运行 on p0 or p6)

在一个核心上,您的循环应该 运行 每次迭代 2 个周期,延迟瓶颈。它是 5 个融合域微指令,因此需要 1.25 个周期才能发出。 (与 test 不同,jnz 不能与 vptest 进行宏融合。 使用超线程,前端已经是比延迟更严重的瓶颈。每个线程可以每隔一个周期发出 4 微指令,这小于依赖链瓶颈的每隔一个周期 5 微指令。

(这对于最近的 Intel 来说很常见,尤其是 SKL/KBL:许多 uops 有足够的端口可供选择,维持每个时钟 4 uops 的吞吐量是现实的,尤其是 SKL 提高了 uop 缓存和解码器的吞吐量以避免由于前端限制而不是后端填满而导致的问题气泡。)

每次一个线程停止(例如,对于分支预测错误),前端可以赶上另一个线程,并将大量未来迭代放入无序核心中,以便它一次咀嚼每 2 个周期迭代一次。 (或更少,因为执行端口吞吐量限制,见下文)。


执行端口吞吐量(未融合域):

p6 上每 5 微码 运行s 中只有 1 个(jnz)。它不可能成为瓶颈,因为前端发布率限制我们在 运行 执行此循环时每个时钟发布少于一个分支。

每次迭代的其他 4 个向量 ALU 微指令必须 运行 在具有向量执行单元的 3 个端口上。 p01 和 p015 微指令具有足够的调度灵活性,没有单个端口会成为瓶颈,所以我们可以只看总的 ALU 吞吐量。对于 3 个端口,这是 4 uops / iter,对于每 1.333 个周期一个 iter 的物理内核的最大平均吞吐量。

对于单线程(无HT)来说,这还不是最严重的瓶颈。但是如果有两个超线程,那就是每 2.6666 个周期一个迭代器。

超线程应该使您的执行单元饱和,并留出一些前端吞吐量。每个线程应平均每 2.666c 发出一个,前端能够以每 2.5c 发出一个。由于延迟仅将您限制为每 2c 一个,因此它可以在由于资源冲突导致关键路径上的任何延迟之后赶上。 (一个 vptest uop 从其他两个 uops 中窃取了一个周期)。

如果您可以更改循环以降低检查频率或使用更少的向量微指令,那可能会成功。但我想的是 更多 向量微指令(例如 vpand 而不是 vptest 然后 vpor 在检查之前将其中的几个结果放在一起... 或 vpxorvptest 时生成全零向量)。也许如果有向量 XNOR 之类的东西,但没有。


要检查实际发生了什么,您可以使用性能计数器来分析您当前的代码,并查看您为整个核心(不仅仅是单独的每个逻辑线程)获得的 uop 吞吐量。或者分析一个逻辑线程,看看它是否饱和了大约 p015 的一半。