总结:16位指令直接不是问题。 问题是在写入部分寄存器后读取更宽的寄存器,导致 Core2 上的部分寄存器停顿。这在 Sandybridge 和后来的问题上要少得多,因为它们合并的成本要低得多。 mov ax, bx 导致额外的合并,但即使是 OP 的“快速”版本也有一些停顿。
请参阅此答案的末尾以获取另一个标量内循环,它应该比其他两个答案更快,使用 shld 在寄存器之间随机播放字节。在循环外预移位 8b 的内容会将我们想要的字节放在每个寄存器的顶部,这使得这非常便宜。它应该在 32 位核心 2 上以每 4 个时钟周期进行一次迭代的速度运行略好,并且使所有三个执行端口饱和而没有停顿。它应该在 Haswell 上以每 2.5c 一次迭代运行。
要真正快速地做到这一点,请查看 auto-vectorized compiler output,可能会削减它或使用向量内在函数重新实现。
与 16 位操作数大小的指令速度慢的说法相反,Core2 在理论上可以维持每个时钟交替 mov ax, bx 和 mov ecx, edx 的 3 个 insn。没有任何类型的“模式开关”。 (正如大家所指出的,“上下文切换”是一个糟糕的选择名称,因为它已经具有特定的技术含义。)
问题是当您读取之前只写了一部分的 reg 时,部分寄存器会停止。 Intel P6 系列 CPU 不会强制写入 ax 以等待 eax 的旧内容准备好(错误依赖),而是单独跟踪部分 reg 的依赖关系。读取更广泛的 reg 会强制合并,根据Agner Fog 会停滞 2 到 3 个周期。使用 16 位操作数大小的另一个大问题是立即数操作数,您可以在英特尔 CPU 上的解码器中遇到不适合 imm8 的立即数的 LCP 停顿。
SnB 系列效率更高,只需插入一个额外的 uop 即可进行合并,而不会在合并时停止。 AMD 和 Intel Silvermont(和 P4)根本不会单独重命名部分寄存器,因此它们确实对以前的内容具有“错误”依赖关系。在这种情况下,我们稍后会读取完整的寄存器,所以这是一个真正的依赖关系,因为我们想要合并,所以这些 CPU 具有优势。 (英特尔 Haswell/Skylake(可能还有 IvB)不会将 AL 与 RAX 分开重命名;它们只会分别重命名 AH/BH/CH/DH。读取 high8 寄存器有额外的延迟。
见this Q&A about partial registers on HSW/SKL for the details。)
partial-reg 停顿都不是长依赖链的一部分,因为合并的 reg 在下一次迭代中被覆盖。显然 Core2 只是停止了前端,甚至是整个乱序执行核心?我的意思是问一个问题,关于 Core2 上的部分寄存器减速有多昂贵,以及如何衡量 SnB 的成本。 @user786653 的 oprofile 答案对此有所启发。 (并且还有一些非常有用的 C 从 OP 的 asm 逆向工程,以帮助弄清楚这个函数真正想要完成的事情)。
用现代 gcc 编译 C 可以生成向量化的 asm,在 xmm 寄存器中一次循环 4 个双字。不过,当它可以使用 SSE4.1 时,它会做得更好。 (并且 clang 根本不会使用 -march=core2 对其进行自动矢量化,但它确实展开了很多,可能会交错多次迭代以避免部分注册的东西。)如果你不告诉 gcc dest 是对齐的,它会在矢量化循环周围生成大量标量序言/尾声,以达到对齐的点。
它将整数 args 转换为向量常量(在堆栈上,因为 32 位代码只有 8 个向量寄存器)。 The inner loop is
.L4:
movdqa xmm0, XMMWORD PTR [esp+64]
mov ecx, edx
add edx, 1
sal ecx, 4
paddd xmm0, xmm3
paddd xmm3, XMMWORD PTR [esp+16]
psrld xmm0, 8
movdqa xmm1, xmm0
movdqa xmm0, XMMWORD PTR [esp+80]
pand xmm1, xmm7
paddd xmm0, xmm2
paddd xmm2, XMMWORD PTR [esp+32]
psrld xmm0, 16
pand xmm0, xmm6
por xmm0, xmm1
movdqa xmm1, XMMWORD PTR [esp+48]
paddd xmm1, xmm4
paddd xmm4, XMMWORD PTR [esp]
pand xmm1, xmm5
por xmm0, xmm1
movaps XMMWORD PTR [eax+ecx], xmm0
cmp ebp, edx
ja .L4
请注意,整个循环中只有一家商店。所有负载只是它之前计算的向量,作为局部变量存储在堆栈中。
有几种方法可以加快 OP 的代码速度。最明显的是我们不需要创建堆栈帧,从而释放ebp。它最明显的用途是保存cr,OP 会将其溢出到堆栈中。 user786653 的triAsm4 这样做了,除了他使用了疯狂的巨魔逻辑变体:他制作了一个堆栈帧并像往常一样设置ebp,但随后将esp 存储在一个静态位置并将其用作暂存寄存器! !如果您的程序有任何信号处理程序,这显然会严重破坏,但除此之外很好(除了使调试更加困难)。
如果您想使用esp 作为草稿,请将函数 args 也复制到静态位置,这样您就不需要寄存器来保存任何指向堆栈内存的指针。 (将旧的esp 保存在 MMX 寄存器中也是一种选择,因此您可以在多个线程同时使用的可重入函数中执行此操作。但如果您将 args 复制到某个静态的地方,除非它是线程本地存储带有段覆盖或其他东西。您不必担心从同一个线程内重新进入,因为堆栈指针处于不可用状态。任何可以在同一个线程中重新进入您的函数的信号处理程序反而会崩溃。>.
溢出cr 实际上不是最佳选择:我们可以只在寄存器中保留一个 dst 指针,而不是使用两个寄存器进行循环(计数器和指针)。通过计算结束指针(结束后的指针:dst+4*cnt)来执行循环边界,并使用带有内存操作数的cmp 作为循环条件。
在 Core2 上与 cmp/jb 进行比较实际上比 dec / jge 更优化。无符号条件可以与cmp 进行宏融合。在 SnB 之前,只有 cmp 和 test 可以进行宏熔断。 (对于 AMD Bulldozer 也是如此,但 cmp 和 test 可以与 AMD 上的任何 jcc 融合)。 SnB 系列 CPU 可以宏熔断 dec/jge。有趣的是,Core2 只能将带符号的比较(如jge)与test 进行宏融合,而不是cmp。 (无论如何,无符号比较是地址的正确选择,因为0x8000000 并不特殊,但0 是。我没有将jb 用作有风险的优化。)
我们不能将cb 和dcb 预移到低字节,因为它们需要在内部保持更高的精度。但是,我们可以 left 移动另外两个,因此它们位于寄存器的左边缘。将它们右移到它们的目标位置不会留下任何可能溢出的垃圾高位。
我们可以做重叠存储,而不是合并到eax。从eax 存储4B,然后从bx 存储低2B。这将保存 eax 中的部分注册失速,但会生成一个用于将 bh 合并到 ebx 中,因此价值有限。可能一个 4B 写入和两个重叠的 1B 存储在这里实际上很好,但那开始是很多存储。不过,它可能会分布在足够多的其他指令上,不会成为存储端口的瓶颈。
user786653 的 triAsm3 使用掩码和 or 指令进行合并,这对于 Core2 来说似乎是一种明智的方法。对于 AMD、Silvermont 或 P4,使用 8b 和 16b mov 指令来合并部分寄存器可能实际上是好的。如果你只写 low8 或 low16 来避免合并惩罚,你也可以在 Ivybridge/Haswell/Skylake 上利用它。但是,我对此进行了多项改进,以减少掩蔽。
;使用定义你可以把 [] 放在周围,所以很明显它们是内存引用
; % 定义 cr ebp+0x10
%define cr esp+取决于我们推了多少的东西
% 定义 dcr ebp+0x1c ;;也将这些更改为从 ebp 工作。
%define dcg ebp+0x20
%define dcb ebp+0x24
; esp-relative 偏移量可能是错误的,只是在没有测试的情况下很快就在我的脑海中做了:
;我们在 ebp 之后再推送 3 个 reg,这是 ebp 在堆栈帧版本中快照 esp 的点。所以加 0xc(即在心里加 0x10 并减去 4)
;无论如何,32位代码是愚蠢的。 64 位在 regs 中传递 args。
%define dest_arg esp+14
% 定义 cnt_arg esp+18
...其他一切
tri_pjc:
推送ebp
推送编辑
推esi
推 ebx ;只有这 4 个需要保留在正常的 32 位调用约定中
mov ebp, [cr]
mov esi, [cg]
mov edi, [cb]
shl esi, 8 ;将我们想要的位放在高边沿,因此我们不必在移入零后进行屏蔽
shl [dcg], 8
shl edi, 8
shl [dcb], 8
;显然,原始代码并不关心 cr 是否溢出到最高字节。
mov edx, [dest_arg]
mov ecx, [cnt_arg]
lea ecx, [edx + ecx*4] ;一过去,用作循环边界
mov [dest_arg], ecx ;将它溢出回堆栈,我们只需要读取它。
对齐 16
。环形: ;见下文,这个内部循环可以更加优化
添加 esi,[dcg]
mov eax, esi
shr eax, 24 ; eax 字节 = { 0 0 0 cg }
添加 edi,[dcb]
shld eax, edi, 8 ; eax 字节 = { 0 0 cg cb }
添加 ebp, [dcr]
mov ecx, ebp
和 ecx, 0xffff0000
或 eax, ecx ; eax bytes = { x cr cg cb} 其中 x 是从 cr 溢出的。通过将掩码更改为 0x00ff0000 来杀死它
;另一个要合并的 shld 可能在其他 CPU 上更快,但不是 core2
;与 mov cx 合并,ax 也可以在价格便宜的 CPU 上(AMD 和 Intel IvB 及更高版本)
mov DWORD [edx], eax
;或者:
; mov DWORD [edx], ebp
; mov WORD [edx], eax ;这个 insn 替换了 mov/and/or 合并
添加 edx, 4
cmp edx, [dest_arg] ; core2 可以宏融合 cmp/unsigned 条件,但没有签名
jb .循环
流行音乐
流行音乐
流行音乐
流行音乐
ret
在完成省略帧指针并将循环边界放入内存之后,我得到的寄存器比我需要的多一个。您可以在寄存器中缓存一些额外的东西,或者避免保存/恢复寄存器。也许将循环边界保持在ebx 是最好的选择。它基本上节省了一个序言指令。将dcb 或dcg 保存在寄存器中需要在序言中额外添加一个insn 才能加载它。 (即使在 Skylake 上,具有内存目标的移位也很丑陋且缓慢,但代码量很小。它们不在循环中,并且 core2 没有 uop 缓存。单独加载/移位/存储仍然是 3 uop,因此,除非您将其保存在 reg 中而不是存储中,否则您无法击败它。)
shld 是 P6 (Core2) 上的 2-uop insn。幸运的是,对循环进行排序很容易,因此它是第五条指令,前面是四个单指令。它应该作为第二组 4 个中的第一个 uop 击中解码器,因此它不会导致前端延迟。 (Core2 can decode 1-1-1-1、2-1-1-1、3-1-1-1 或 4-1-1-1 uops-per-insn 模式。SnB 和后来重新设计了解码器,并添加了一个 uop 缓存,这使得解码通常不是瓶颈,并且只能处理 1-1-1-1、2-1-1、3-1 和 4 组。)
shld 是 horrible on AMD K8, K10, Bulldozer-family, and Jaguar。 6 m-ops、3c 延迟和每 3c 吞吐量一个。它在 32 位操作数大小的 Atom/Silvermont 上很棒,但在 16 或 64b 寄存器时就很糟糕了。
这个 insn 排序可能会使用 cmp 作为组的最后一个 insn 进行解码,然后将 jb 单独解码,使其不是宏熔断器。如果前端效果是此循环的一个因素,这可能会为合并的重叠存储方法带来额外的优势,而不仅仅是节省 uop。 (而且我怀疑它们会是,考虑到高度的并行性并且循环承载的 dep 链很短,因此可以同时进行多次迭代。)
所以:每次迭代的融合域微指令:Core2 上 13 个(假设宏融合实际上可能不会发生),SnB 系列上 12 个。所以 IvB 应该在每 3c 一次迭代中运行它(假设 3 个 ALU 端口都不是瓶颈。mov r,r 不需要 ALU 端口,存储也不需要。add 和布尔值可以使用任何端口。@ 987654381@ 和 shld 是唯一不能在广泛选择的端口上运行的,并且每三个周期只有两个班次。)即使它设法避免任何前端瓶颈,Core2 每次迭代也需要 4c 来发布它,甚至更长的运行时间。
我们在 Core2 上的运行速度可能仍然足够快,如果我们仍然这样做,那么每次迭代将cr 溢出/重新加载到堆栈将成为瓶颈。它将内存往返 (5c) 添加到循环携带的依赖链中,使总 dep 链长度为 6 个循环(包括添加)。
嗯,实际上即使 Core2 也可能会从使用两个 shld insns 合并中获胜。它还保存了另一个寄存器!
对齐 16
;mov ebx, 111 ; IACA 开始
;db 0x64, 0x67, 0x90
。环形:
添加 ebp, [dcr]
mov eax, ebp
shr eax, 16 ; eax bytes = { 0 0 x cr} 其中 x 是从 cr 溢出的。像其他人一样杀死那个预移动 cr 和 dcr,并在此处使用 shr 24
添加 esi,[dcg]
shld eax, esi, 8 ; eax 字节 = { 0 x cr cg}
添加 edx, 4 ;这介于 `shld` 之间,以帮助提高前 SnB 上的解码器吞吐量,并且不破坏宏融合。
添加 edi,[dcb]
shld eax, edi, 8 ; eax 字节 = { x cr cg cb}
mov DWORD [edx-4], eax
cmp edx, ebx ;在这里使用我们的备用寄存器
jb .循环; core2 可以宏融合 cmp/unsigned 条件,但没有签名。宏融合仅在 Core2 上以 32 位模式工作。
;mov ebx, 222 ; IACA 结束
;db 0x64, 0x67, 0x90
每次迭代:SnB:10 个融合域微指令。 Core2:12 个融合域微指令,所以这个 比 Intel CPU 上的先前版本短(但在 AMD 上很糟糕)。使用shld 可以节省mov 指令,因为我们可以使用它来非破坏性地提取源的高字节。
Core2 可以每 3 个时钟迭代一次循环。 (这是 Intel 第一个具有 4 uop 宽流水线的 CPU)。
来自Agner Fog's table 为Merom/Conroe (first gen Core2)(请注意,David Kanter 的框图将 p2 和 p5 颠倒了):
-
shr: 在 p0/p5 上运行
-
shld:p0/p1/p5 2 微指令? Agner 的 pre-Haswell 表没有说明哪些 uops 可以去哪里。
-
mov r,r,add,and:p0/p1/p5
- 融合 cmp-and-branch: p5
- 存储:p3 和 p4(这些微融合到 1 个融合域存储 uop)
- 每个负载:p2。 (所有负载都与融合域中的 ALU 操作进行微融合)。
根据 IACA,它有一个 Nehalem 模式,但没有 Core2,大部分shld uops 都转到 p1,在其他端口上运行的每个 insn 平均只有不到 0.6 个。 Nehalem 具有与 Core2 基本相同的执行单元。此处涉及的所有指令在 NHM 和 Core2 上具有相同的 uop 成本和端口要求。 IACA 的分析对我来说看起来不错,我不想自己检查所有内容来回答一个 5 年前的问题。不过,回答很有趣。 :)
无论如何,根据 IACA 的说法,微指令应该在端口之间很好地分布。它认为 Nehalem 可以每 3.7 个周期运行一次循环,使所有三个执行端口饱和。它的分析对我来说看起来不错。 (请注意,我必须从cmp 中删除内存操作数,以使 IACA 不会给出愚蠢的结果。)这显然是需要的,因为 pre-SnB 每个周期只能执行一次加载:我们会在端口 2 上遇到四次加载的瓶颈在循环中。
IACA 不同意 Agner Fog 对 IvB 和 SnB 的测试(根据我对 SnB 的测试,它认为 shld 仍然是 2 uop,而实际上它是 1)。所以它的数字很傻。
IACA 看起来对 Haswell 来说是正确的,它说瓶颈是前端。它认为 HSW 可以以每 2.5c 一个的速度运行它。 (Haswell 中的循环缓冲区至少可以在每次迭代中发出非整数循环数的循环。Sandybridge may be limited to whole numbers of cycles, where the taken loop-branch ends an issue-group。)
我还发现我需要使用iaca.sh -no_interiteration,否则它会认为存在迭代循环携带的依赖项,并认为循环在 NHM 上会占用 12c。