重要的背景知识:Agner Fog的microarch pdf,以及Ulrich Drepper的《每个程序员应该了解的内存》。另请参阅x86标记Wiki,尤其是Intel的优化手册,以及David Kanter 对Haswell微体系结构的分析,并带有图表。
任务很酷;比我所见过的要好得多,那里我被要求为学生优化一些代码gcc -O0
,学习一堆对实际代码无关紧要的技巧。在这种情况下,系统会要求您了解CPU管道,并用它来指导您的优化工作,而不仅仅是盲目的猜测。 这其中最有趣的部分是用“恶魔般的无能”而不是故意的恶意来证明每个悲观的理由。
作业措辞和代码存在问题:
此代码的特定于uarch的选项受到限制。它不使用任何数组,大部分成本是对exp
/ log
库函数的调用。没有一个或多或少的指令级并行性的明显方法,循环承载的依赖链非常短。
我很乐意看到一个答案,该答案试图通过重新安排表达式来更改依赖项来降低速度,从而仅从依赖项(危害)中减少ILP,从而减慢速度。 我没有尝试过。
英特尔Sandybridge系列CPU是激进的无序设计,需要花费大量的晶体管和功能来寻找并行性,并避免可能困扰传统RISC有序流水线的危险(依赖性)。通常,减慢速度的唯一传统危险是RAW“真实”依赖性,这些依赖性会导致吞吐量受延迟的限制。
由于寄存器更名,寄存器的战争和战争危险几乎不是问题。(popcnt
/lzcnt
/除外,它们对Intel CPU的目标tzcnt
具有错误的依赖关系,即使它是只写的,即WAW被视为RAW危险+写)。对于内存排序,现代CPU使用存储队列将提交到缓存的时间延迟到退役之前,这也避免了WAR和WAW的危害。
为什么mulss在Haswell上只需要3个周期,而不同于Agner的指令表?关于寄存器重命名和在FP点积循环中隐藏FMA延迟的更多信息。
Nehalem(Core2的后继产品)引入了“ i7”品牌名称,一些英特尔手册甚至在看起来像Nehalem时都说“ Core i7”,但他们保留了Sandybridge和以后的微体系结构的“ i7”品牌。 SnB是P6家族进化为新物种SnB家族的时候。在许多方面,Nehalem与Pentium III的共同点比与Sandybridge的共同点更多(例如,寄存器读取停顿和ROB读取停顿不会在SnB上发生,因为它已更改为使用物理寄存器文件。还有uop缓存和不同的内部uop格式)。 术语“ i7体系结构”没有用,因为将SnB系列与Nehalem而不是Core2组合在一起几乎没有意义。(不过,Nehalem确实引入了共享包容性L3缓存体系结构,用于将多个内核连接在一起。还集成了GPU。因此在芯片级,命名更有意义。)
恶魔般的无能为力的好点子摘要
即使是完全没有能力的人,也不太可能添加明显无用的工作或无限循环,并且使C ++ / Boost类陷入混乱不在分配的范围之内。
- 具有单个共享
std::atomic<uint64_t>
循环计数器的多线程,因此发生了正确的迭代总数。原子uint64_t尤其糟糕-m32 -march=i586
。对于加分点,请使其不对齐,并以不均匀的分割(不是4:4)穿过页面边界。
- 错误共享其他一些非原子变量->清除内存顺序错误推测管道,以及额外的高速缓存未命中。
- 而不是
-
在FP变量上使用,而是对高字节与0x80进行异或运算以翻转符号位,从而导致存储转发停顿。
- 每次迭代都要独立计时,甚至比还要重
RDTSC
。例如CPUID
/ RDTSC
或进行系统调用的时间函数。序列化指令本质上是管道不友好的。
- 变化乘以常数,除以常数的倒数(“为便于阅读”)。 div速度慢且未完全流水线化。
- 使用AVX(SIMD)向量化乘法/平方,但
vzeroupper
在调用标量数学库exp()
和log()
函数之前无法使用,从而导致AVX <-> SSE转换停顿。
- 将RNG输出存储在链接列表中,或存储在无序遍历的数组中。每次迭代的结果相同,最后求和。
这个答案也包括在内,但不包括在摘要中:建议在非流水线CPU上运行的速度一样慢,或者即使是恶魔般的无能也似乎没有道理。例如,许多编译器想法产生了明显不同/更差的汇编。
多线程严重
也许使用OpenMP进行很少迭代的多线程循环,其开销远大于速度增益。但是,蒙特卡洛代码具有足够的并行度,实际上可以提高速度。如果我们成功地使每次迭代变慢。(每个线程计算一个partial payoff_sum
,最后添加)。 #omp parallel
在那个循环上可能是一个优化,而不是悲观。
多线程,但强制两个线程共享同一个循环计数器(具有atomic
增量,因此迭代总数正确)。这似乎是有害的逻辑。这意味着将static
变量用作循环计数器。这证明了使用atomic
for循环计数器的合理性,并创建了实际的高速缓存行ping-ponging(只要线程不在具有超线程的同一个物理内核上运行;这可能不会那么慢)。无论如何,这比的无可争议的情况要慢得多lock inc
。并lock cmpxchg8b
以原子方式增加了竞争uint64_t
32位系统上则要重试在一个循环而不是硬件的仲裁原子inc
。
还创建错误共享,其中多个线程将其私有数据(例如RNG状态)保留在同一缓存行的不同字节中。 (有关它的Intel教程,包括要查看的perf计数器)。 这有一个微体系结构特定的方面:英特尔CPU推测不会发生内存错误排序,并且至少在P4上有一个内存顺序机器清除性能事件可以检测到这一点。在Haswell上的惩罚可能不会那么大。正如该链接所指出的那样,lock
ed指令假定会发生这种情况,从而避免了错误的猜测。正常负载推测在执行负载和按程序顺序退出之间,其他内核不会使缓存行无效(除非您使用pause
)。没有lock
ed指令的真正共享通常是一个错误。将非原子共享循环计数器与原子案例进行比较会很有趣。实际上,要保持悲观,请保留共享的原子循环计数器,并在其他变量的相同或不同的缓存行中引起错误的共享。
特定于uarch的随机想法:
如果您可以引入任何无法预测的分支,那将大大简化代码。现代的x86 CPU具有很长的流水线,因此错误预测会花费约15个周期(从uop缓存运行时)。
依赖链:
我认为这是作业的预期部分之一。
通过选择具有一个长依赖性链而不是多个短依赖性链的操作顺序,可以破坏CPU利用指令级并行性的能力。除非使用-ffast-math
,否则不允许编译器更改FP计算的操作顺序,因为这可能会更改结果(如下所述)。
为了真正有效地执行此操作,请增加循环承载的依赖链的长度。但是,没有什么比这明显的了:编写的循环具有非常短的循环承载的依赖链:只是FP添加。(3个周期)。多个迭代可以立即进行计算,因为它们可以payoff_sum +=
在上一次迭代结束之前早点开始。(log()
并exp
接受许多指令,但不多于Haswell用来查找并行性的乱序窗口:ROB大小= 192融合域uops,而调度程序大小= 60未融合域uops。一旦当前迭代的执行进展到足以为下一个迭代发出的指令腾出空间,当较旧的指令离开执行单元时,其输入准备就绪的任何部分(即独立/独立的Dep链)都可以开始执行。免费(例如,因为瓶颈是延迟,而不是吞吐量)。
RNG状态几乎可以肯定是比更长的循环承载依赖链addps
。
使用较慢/更多的FP操作(尤其是更多的划分):
除以2.0而不是乘以0.5,依此类推。FP乘法在Intel设计中流水线较多,在Haswell及更高版本上每0.5c吞吐量有一个。 FP divsd
/ divpd
仅部分流水线。(尽管Skylake的每4c吞吐量令人印象深刻divpd xmm
,延迟为13-14c,而Nehalem(7-22c)根本没有流水线。)
该do { ...; euclid_sq = x*x + y*y; } while (euclid_sq >= 1.0);
显然是测试的距离,所以显然这将是适当的,以sqrt()
它。:P(sqrt
甚至比还要慢div
)。
正如@Paul Clayton所建议的那样,使用关联/分布等效项重写表达式会带来更多工作(只要您不使用-ffast-math
允许编译器重新优化的方法即可)。 (exp(T*(r-0.5*v*v))
可能成为exp(T*r - T*v*v/2.0)
。请注意,虽然实数数学是关联的,但即使不考虑溢出/ NaN ,浮点数学也不是(这就是为什么-ffast-math
默认情况下不启用)。请参阅Paul的评论,以获得非常多毛的嵌套pow()
建议。
如果您可以将计算结果缩小到非常小的数字,那么当对两个正数的运算产生反范数时,FP数学运算会花费大约120个额外的周期来捕获微码。有关确切的数字和详细信息,请参见Agner Fog的microarch pdf。这是不可能的,因为您有很多乘数,因此比例因子将被平方并一直下溢至0.0。我看不出有什么方法可以证明无能(即使是恶魔般)的必要缩放,只是故意的恶意。
如果可以使用内在函数(<immintrin.h>
)
用于movnti
从缓存中逐出数据。恶魔般的:它是新的且无序的,因此应该让CPU更快地运行,对吗?或者,在某人有可能危险地执行此操作的情况下,请查看该链接的问题(对于仅在某些位置很热的分散写操作)。 clflush
没有恶意可能是不可能的。
在FP数学运算之间使用整数混洗会导致旁路延迟。
混合SSE和AVX指令不正确使用的vzeroupper
预SKYLAKE微架构的原因大排挡(和不同的惩罚在SKYLAKE微架构)。即使没有这种情况,严重的矢量化也可能比标量更糟糕(将数据混入/移出矢量所花费的周期要比对256b个矢量一次进行4次Monte-Carlo迭代的add / sub / mul / div / sqrt操作节省的周期要多) 。add / sub / mul执行单元是完全流水线和全角的,但是256b向量上的div和sqrt却不如128b向量(或标量)上的div和sqrt快,因此,的加速并不显着double
。
exp()
并且log()
没有硬件支持,因此该部分将需要将向量元素提取回标量并分别调用库函数,然后将结果重新排列回向量。libm通常被编译为仅使用SSE2,因此将使用标量数学指令的传统SSE编码。如果您的代码使用256b向量并且exp
没有先执行调用vzeroupper
,那么您将停顿。返回后,像vmovsd
将下一个矢量元素设置为arg 的AVX-128指令exp
也将停止。然后exp()
,当它运行SSE指令时将再次停顿。 这正是此问题中发生的事情,导致速度降低了10倍。 (感谢@ZBoson)。
有关此代码,另请参阅Nathan Kurz对Intel的math lib与glibc进行的实验。未来的glibc将带有的向量化实现exp()
等。
如果定位到前IvB或esp。Nehalem,尝试让gcc导致16位或8位操作后跟32位或64位操作导致部分寄存器停顿。在大多数情况下,gcc将movzx
在8位或16位操作之后使用,但是在这种情况下,gcc会进行修改ah
,然后读取ax
使用(内联)asm:
使用(inline)asm,您可能会破坏uop缓存:不能容纳在三个6uop缓存行中的32B代码块会强制从uop缓存切换到解码器。在内部循环内的分支目标上ALIGN
使用许多单字节nop
s而不是几个long nop
的不称职可能会解决问题。或者将对齐填充放在标签之后,而不是之前。:P仅当前端是一个瓶颈时才重要,而如果我们成功地对其余代码进行了悲观就不会如此。
使用自我修改代码触发管道清除(又称机器核)。
LCP因16位指令的停顿而导致立即数太大而无法容纳8位,因此不太可能有用。SnB及以后版本的uop缓存意味着您只需支付一次解码费用。在Nehalem(第一个i7)上,它可能适用于不适合28 uop循环缓冲区的循环。gcc有时会生成此类指令,即使-mtune=intel
使用32位指令时也是如此。
定时的一个常见用法是CPUID
(序列化)RDTSC
。使用CPUID
/ 分别为每个迭代计时RDTSC
,以确保RDTSC
不会按照较早的指令对它们进行重新排序,这会使事情变慢很多。(在现实生活中,计时的明智方法是将所有迭代计时在一起,而不是分别计时并累加起来)。
导致大量缓存未命中和其他内存减慢
union { double d; char a[8]; }
对一些变量使用a 。 通过仅对一个字节进行窄存储(或“读-修改-写”)而导致存储转发停顿。(该Wiki文章还介绍了有关加载/存储队列的许多其他微体系结构内容)。例如,仅对高字节而不是运算符使用XOR 0x80 翻转符号double
-
。这位无能的开发者可能听说过FP比整数慢,因此尝试使用整数op尽可能多地做。(针对SSE寄存器中FP数学的优秀编译器可能会将其编译为xorps
在另一个xmm寄存器中使用一个常量,但是对于x87来说,这并不可怕,唯一的方法是,如果编译器意识到它正在取反值,并用减法替换下一个加法。)
使用volatile
,如果你有编译-O3
和不使用std::atomic
,强制编译实际存储/重装所有的地方。全局变量(而不是局部变量)也会强制执行一些存储/重装操作,但是C ++内存模型的弱排序并不要求编译器始终将溢出/重装到内存中。
用大结构的成员替换局部变量,这样就可以控制内存布局。
在结构中使用数组进行填充(并存储随机数以证明它们的存在)。
选择您的内存布局,以便所有内容进入L1缓存中同一“集合”的不同行。它只有8路关联,即每组有8条“路”。高速缓存行为64B。
更好的是,将事物精确地分开4096B,因为加载对存储到不同页面的错误依赖关系,但页面内的偏移量相同。激进的乱序CPU使用内存消除歧义来确定何时可以重新排序加载和存储而无需更改结果,并且Intel的实现具有错误的肯定判断,从而阻止了加载的提早开始。他们可能只检查页面偏移量以下的位,因此检查可以在TLB将高位从虚拟页面转换为物理页面之前开始。除了Agner的指南之外,请参见Stephen Canon的答案,以及@Krazy Glew对同一问题的答案结尾附近的部分。(Andy Glew是英特尔最初的P6微体系结构的架构师之一。)
使用__attribute__((packed))
让您误对准变量,使它们跨越高速缓存行或偶数页边界。(因此,一个负载double
需要来自两条高速缓存行的数据)。除交叉高速缓存行和页面行外,未对齐的负载在任何Intel i7 uarch中均不受影响。 高速缓存行拆分仍然需要额外的周期。Skylake将页面拆分加载的代价从100个周期减少到5个周期。(第2.1.3节)。也许与能够并行执行两个页面遍历有关。
在上进行页面拆分atomic<uint64_t>
应该是最糟糕的情况,尤其是。如果它是一页中的5个字节,另一页中的3个字节,或4:4以外的任何其他值。在某些Iarch上使用16B向量对缓存行进行分割时,即使在中间进行分割也更有效。将所有内容放入alignas(4096) struct __attribute((packed))
(当然是为了节省空间),包括一个用于存储RNG结果的数组。通过使用uint8_t
或uint16_t
在计数器前放置东西来实现未对准。
如果您可以让编译器使用索引寻址模式,那将击败uop micro-fusion。也许通过使用#define
s将简单的标量变量替换为my_data[constant]
。
如果您可以引入一个更高级别的间接寻址,那么您就不会很早就知道加载/存储地址,这可能会进一步造成负面影响。
以非连续顺序遍历数组
我认为我们可以首先提出引入数组的不称职理由:它使我们可以将随机数生成与随机数使用分开。每次迭代的结果也可以存储在一个数组中,以便以后进行求和(具有更多的恶魔般的能力)。
对于“最大随机性”,我们可以让一个线程在随机数组上循环,向其中写入新的随机数。消耗随机数的线程可以生成随机索引以从中加载随机数。(这里有一些工作,但是在微体系结构上,它有助于及早知道加载地址,因此可以在需要加载的数据之前解决任何可能的加载延迟。)在不同的内核上使用读写器会导致内存排序错误-推测管道清除(如先前在虚假共享情况下所述)。
为了获得最大的悲观度,请以4096字节的步幅(即512倍)遍历数组。例如
for (int i=0 ; i<512; i++)
for (int j=i ; j<UPPER_BOUND ; j+=512)
monte_carlo_step(rng_array[j]);
因此,访问模式为0,4096,8192,...,
8,4104,8200,...
16,4112,8208,...
这就是以double rng_array[MAX_ROWS][512]
错误的顺序访问2D数组所得到的结果(循环遍历行,而不是内部循环中的行中的列,如@JesperJuhl所建议)。如果恶魔般的无能行为可以证明二维数组具有这样的尺寸,那么花园中各种现实世界的无能行为就很容易以错误的访问模式来证明循环。这发生在现实生活中的真实代码中。
如果数组不那么大,请根据需要调整循环范围,以使用许多不同的页面,而不是重复使用相同的几页。跨页面无法(完全(或完全))执行硬件预取。预取器可以跟踪每个页面中的一个前向流和一个后向流(在这里发生这种情况),但是仅当内存带宽尚未被非预取所饱和时,才对它进行操作。
这也将产生大量TLB未命中,除非该网页被合并成一个hugepage(Linux确实这个机会主义匿名(没有文件支持)的分配一样malloc
/ new
在使用mmap(MAP_ANONYMOUS)
)。
可以使用链接列表来代替存储结果列表的数组。然后,每次迭代都需要指针追逐负载(下一个负载的负载地址存在RAW真正的依赖危险)。使用错误的分配器,您可能设法分散内存中的列表节点,从而击败缓存。如果使用了令人讨厌的分配器,它将每个节点放在其自己页面的开头。(例如,mmap(MAP_ANONYMOUS)
直接分配,而不会破坏页面或跟踪对象大小以正确支持free
)。
这些并不是真正的微体系结构特定的,并且与管道没有什么关系(大多数非管道CPU也会减慢速度)。
有点偏离主题:使编译器生成更糟糕的代码/做更多工作:
使用C ++ 11 std::atomic<int>
和std::atomic<double>
以获得最简洁的代码。lock
即使没有其他线程的争用,MFENCEs和ed指令也非常慢。
-m32
会使代码变慢,因为x87代码比SSE2代码差。基于堆栈的32位调用约定接受更多指令,甚至将堆栈上的FP args传递给诸如之类的函数exp()
。 atomic<uint64_t>::operator++
开启-m32
需要lock cmpxchg8B
循环(i586)。(因此,请将其用于循环计数器![邪恶的笑声])。
-march=i386
也会感到悲观(感谢@Jesper)。FP fcom
比686要慢fcomi
。586之前的版本没有提供原子的64位存储(更不用说cmpxchg了),因此所有64位atomic
操作都编译为libgcc函数调用(可能是为i686编译的,而不是实际使用的锁)。在上一段中的Godbolt编译器资源管理器链接上进行尝试。
在sizeof()为10或16(带有用于对齐的填充)的ABI中,使用long double
/ sqrtl
/ expl
可提高精度和减慢速度long double
。(IIRC,64位Windows应用8字节long double
等效于double
(无论如何,负载/ 10byte的商店(80bit的)FP操作数是4/7微指令,与float
或double
仅服用1 UOP各为fld m64/m32
/ fst
)。强制的x87与long double
负自动矢量即使对于海湾合作委员会-m64 -march=haswell -O3
。
如果不使用atomic<uint64_t>
循环计数器,请使用long double
所有功能,包括循环计数器。
atomic<double>
进行编译,但+=
不支持类似read-modify-write的操作(即使在64bit上也是如此)。 atomic<long double>
必须仅针对原子加载/存储调用库函数。这可能真的效率很低,因为x86 ISA自然不支持原子的10byte加载/存储,并且我认为不加锁(cmpxchg16b
)的唯一方法是64位模式。
在中-O0
,通过将部分分配给临时var来破坏较大的表达式将导致更多的存储/重载。没有volatile
或没有,这与真实代码的真实构建将使用的优化设置无关。
C别名规则允许a char
别名任何东西,因此通过char*
强制存储可以使编译器在字节存储之前/之后(甚至在)存储/重新加载所有内容-O3
。(例如,这是对在的数组上运行的代码uint8_t
进行自动向量化的问题。)
尝试使用uint16_t
循环计数器,以通过使用16位操作数大小(潜在的停顿)和/或额外的movzx
指令(安全)将截断强制为16位。 签名溢出是未定义的行为,所以,除非你使用-fwrapv
或至少-fno-strict-overflow
,签署了循环计数器不必重新登录扩展每次迭代,即使作为偏移64位指针。
强制从整数到另一个的转换float
。和/或double
<=> float
转换。指令的延迟大于一,并且标量int-> float(cvtsi2ss
)的设计很差,无法将xmm寄存器的其余部分清零。(pxor
出于这个原因,gcc插入了一个额外的代码来打破依赖关系。)
通常将您的CPU关联性设置为其他CPU(@Egwor建议)。恶性推理:您不希望一个内核长时间运行线程而过热,是吗?也许交换到另一个内核将使该内核加速到更高的时钟速度。(实际上:它们彼此之间的温度非常接近,因此除非在多插座系统中,否则这几乎是不可能的)。现在,只是调错了,并且经常这样做。除了在OS保存/恢复线程状态上花费的时间外,新内核还具有冷L2 / L1缓存,uop缓存和分支预测器。
频繁引入不必要的系统调用可能会使您变慢,无论它们是什么。尽管某些重要但简单的方法gettimeofday
可以在用户空间中实现,而无需过渡到内核模式。(Linux上的glibc在内核的帮助下完成了此操作,因为内核会在中导出代码vdso
)。
有关更多系统调用开销(包括返回到用户空间后的缓存/ TLB丢失,而不仅仅是上下文切换本身),FlexSC论文对当前情况进行了一些出色的性能分析,并提出了关于批处理系统的建议来自大型多线程服务器进程的调用。
while(true){}