在英特尔Sandybridge系列CPU中取消对管道程序的优化


322

我已经花了一个星期的时间来尝试完成这项任务,我希望这里的人可以带领我走上正确的道路。让我从讲师的指示开始:

您的分配与我们的第一个实验分配相反,后者是优化素数程序。您在此作业中的目的是简化程序,即使其运行缓慢。这两个都是占用大量CPU的程序。他们需要几秒钟才能在我们的实验室PC上运行。您可能无法更改算法。

要优化程序,请使用有关Intel i7管道运行方式的知识。想像一下重新排序指令路径以引入WAR,RAW和其他危险的方法。想办法最大限度地减少缓存的有效性。令人作呕的无能。

作业中选择了磨刀石或蒙特卡洛程序。缓存效率注释大部分仅适用于Whetstone,但我选择了蒙特卡洛模拟程序:

// Un-modified baseline for pessimization, as given in the assignment
#include <algorithm>    // Needed for the "max" function
#include <cmath>
#include <iostream>

// A simple implementation of the Box-Muller algorithm, used to generate
// gaussian random numbers - necessary for the Monte Carlo method below
// Note that C++11 actually provides std::normal_distribution<> in 
// the <random> library, which can be used instead of this function
double gaussian_box_muller() {
  double x = 0.0;
  double y = 0.0;
  double euclid_sq = 0.0;

  // Continue generating two uniform random variables
  // until the square of their "euclidean distance" 
  // is less than unity
  do {
    x = 2.0 * rand() / static_cast<double>(RAND_MAX)-1;
    y = 2.0 * rand() / static_cast<double>(RAND_MAX)-1;
    euclid_sq = x*x + y*y;
  } while (euclid_sq >= 1.0);

  return x*sqrt(-2*log(euclid_sq)/euclid_sq);
}

// Pricing a European vanilla call option with a Monte Carlo method
double monte_carlo_call_price(const int& num_sims, const double& S, const double& K, const double& r, const double& v, const double& T) {
  double S_adjust = S * exp(T*(r-0.5*v*v));
  double S_cur = 0.0;
  double payoff_sum = 0.0;

  for (int i=0; i<num_sims; i++) {
    double gauss_bm = gaussian_box_muller();
    S_cur = S_adjust * exp(sqrt(v*v*T)*gauss_bm);
    payoff_sum += std::max(S_cur - K, 0.0);
  }

  return (payoff_sum / static_cast<double>(num_sims)) * exp(-r*T);
}

// Pricing a European vanilla put option with a Monte Carlo method
double monte_carlo_put_price(const int& num_sims, const double& S, const double& K, const double& r, const double& v, const double& T) {
  double S_adjust = S * exp(T*(r-0.5*v*v));
  double S_cur = 0.0;
  double payoff_sum = 0.0;

  for (int i=0; i<num_sims; i++) {
    double gauss_bm = gaussian_box_muller();
    S_cur = S_adjust * exp(sqrt(v*v*T)*gauss_bm);
    payoff_sum += std::max(K - S_cur, 0.0);
  }

  return (payoff_sum / static_cast<double>(num_sims)) * exp(-r*T);
}

int main(int argc, char **argv) {
  // First we create the parameter list                                                                               
  int num_sims = 10000000;   // Number of simulated asset paths                                                       
  double S = 100.0;  // Option price                                                                                  
  double K = 100.0;  // Strike price                                                                                  
  double r = 0.05;   // Risk-free rate (5%)                                                                           
  double v = 0.2;    // Volatility of the underlying (20%)                                                            
  double T = 1.0;    // One year until expiry                                                                         

  // Then we calculate the call/put values via Monte Carlo                                                                          
  double call = monte_carlo_call_price(num_sims, S, K, r, v, T);
  double put = monte_carlo_put_price(num_sims, S, K, r, v, T);

  // Finally we output the parameters and prices                                                                      
  std::cout << "Number of Paths: " << num_sims << std::endl;
  std::cout << "Underlying:      " << S << std::endl;
  std::cout << "Strike:          " << K << std::endl;
  std::cout << "Risk-Free Rate:  " << r << std::endl;
  std::cout << "Volatility:      " << v << std::endl;
  std::cout << "Maturity:        " << T << std::endl;

  std::cout << "Call Price:      " << call << std::endl;
  std::cout << "Put Price:       " << put << std::endl;

  return 0;
}

我所做的更改似乎将代码运行时间增加了一秒钟,但是我不完全确定我可以更改哪些内容以停止管道而不添加代码。指向正确方向的指示非常棒,我感谢您的任何答复。


更新:进行此作业的教授发布了一些详细信息

重点是:

  • 这是社区学院的第二学期建筑课程(使用轩尼诗和帕特森教材)。
  • 实验室计算机具有Haswell CPU
  • 学生们已经了解了该CPUID指令以及如何确定高速缓存大小,内在函数和CLFLUSH指令。
  • 允许使用任何编译器选项,内联汇编语言也是如此。
  • 宣布编写自己的平方根算法超出了预期

Cowmoogun在meta线程上的评论表明,尚不清楚编译器优化是否可能是其中的一部分,并且假定-O0,并且运行时增加17%是合理的。

听起来这似乎是作业的目标,是让学生重新排序现有的工作,以减少指令级的并行性或类似的事情,但是,人们更深入地学习并学到更多的知识并不是一件坏事。


请记住,这是一个计算机体系结构问题,而不是有关一般如何使C ++变慢的问题。


97
我听到的i7确实很差用while(true){}
克里夫AB


5
使用OpenMP,如果你做不好,你应该能够使N个线程需要更长的时间比1
柔印

9
这个问题目前正在讨论荟萃
斑的幽灵

3
@bluefeet:我补充说,因为它在重新开放后不到一个小时的时间内就吸引了一个封闭投票。只需5个人即可参加VTC,而无需了解阅读评论就可以看到正在讨论meta。现在还有另外一次近距离投票。我认为至少一个句子将有助于避免关闭/重新打开周期。
彼得·科德斯

Answers:


405

重要的背景知识:Agner Fog的microarch pdf,以及Ulrich Drepper的《每个程序员应该了解的内存》。另请参阅标记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变量用作循环计数器。这证明了使用atomicfor循环计数器的合理性,并创建了实际的高速缓存行ping-ponging(只要线程不在具有超线程的同一个物理内核上运行;这可能不会那么慢)。无论如何,这比的无可争议的情况慢得多lock inc。并lock cmpxchg8b以原子方式增加了竞争uint64_t32位系统上则要重试在一个循环而不是硬件的仲裁原子inc

还创建错误共享,其中多个线程将其私有数据(例如RNG状态)保留在同一缓存行的不同字节中。 (有关它的Intel教程,包括要查看的perf计数器)这有一个微体系结构特定的方面:英特尔CPU推测不会发生内存错误排序,并且至少在P4上有一个内存顺序机器清除性能事件可以检测到这一点。在Haswell上的惩罚可能不会那么大。正如该链接所指出的那样,locked指令假定会发生这种情况,从而避免了错误的猜测。正常负载推测在执行负载和按程序顺序退出之间,其他内核不会使缓存行无效(除非您使用pause)。没有locked指令的真正共享通常是一个错误。将非原子共享循环计数器与原子案例进行比较会很有趣。实际上,要保持悲观,请保留共享的原子循环计数器,并在其他变量的相同或不同的缓存行中引起错误的共享。


特定于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使用许多单字节nops而不是几个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_tuint16_t在计数器前放置东西来实现未对准。

如果您可以让编译器使用索引寻址模式,那将击败uop micro-fusion。也许通过使用#defines将简单的标量变量替换为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微指令,与floatdouble仅服用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论文对当前情况进行了一些出色的性能分析,并提出了关于批处理系统的建议来自大型多线程服务器进程的调用。


10
@JesperJuhl:是的,我会买这个理由。“绝妙的无能”是一个很棒的短语:)
彼得·科德斯

2
将乘以常数乘以常数除以常数可能会降低性能(至少在不试图超越-O3 -fastmath的情况下)。类似地,使用关联性来增加工作量(exp(T*(r-0.5*v*v))成为exp(T*r - T*v*v/2.0)exp(sqrt(v*v*T)*gauss_bm)成为exp(sqrt(v)*sqrt(v)*sqrt(T)*gauss_bm))。关联性(和泛化)也可以转换exp(T*r - T*v*v/2.0)为`pow((pow(e_value,T),r)/ pow(pow(pow((pow(eow_t,v),v),v)),-2.0)[- 。像这样的数学技巧并不真正算作微架构deoptimizations。
保罗·克莱顿

2
我非常感谢您的回应,Agner的Fog起到了很大的帮助作用。我将对此进行摘要,并从今天下午开始进行研究。就实际了解正在发生的事情而言,这可能是最有用的任务。
Cowmoogun

19
这些建议中有一些非常令人讨厌,因此我不得不与教授交谈,以了解现在7分钟的运行时间是否对他来说太长了,他不想坐下来验证输出。仍在使用它,这可能是我在项目中获得的最大乐趣。
Cowmoogun

4
什么?没有互斥锁?同时拥有200万个线程和一个互斥量来保护每个单独的计算(以防万一!)将使地球上最快的超级计算机屈服。就是说,我确实喜欢这个令人讨厌的无能回答。
大卫·哈曼

35

您可以做一些使事情表现尽可能差的事情:

  • 编译i386体系结构的代码。这将阻止使用SSE和较新的说明,并强制使用x87 FPU。

  • std::atomic在各处使用变量。由于迫使编译器到处插入内存屏障,这将使它们非常昂贵。这是一个不称职的人可能为确保“线程安全”而做的事情。

  • 确保以最坏的方式访问内存,以供预取器预测(主列与行主列)。

  • 为了使变量特别昂贵,您可以通过分配变量来确保它们都具有“动态存储持续时间”(已分配堆),new而不是让它们具有“自动存储持续时间”(已分配堆栈)。

  • 确保分配的所有内存都非常奇怪地对齐,并且一定要避免分配大页面,因为这样做会使TLB效率太高。

  • 无论您做什么,都不要在启用编译器优化器的情况下构建代码。并确保启用您可以表达的最具表现力的调试符号(不会使代码运行速度变慢,但是会浪费一些额外的磁盘空间)。

注意:这个答案基本上只是总结了我的评论,@ Peter Cordes已将其纳入他的非常好的答案中。如果您只有一个备用的话,建议他得到您的支持:)


9
对于这些问题,我的主要反对意见是该问题的措词: 要对程序进行优化,请充分利用您对Intel i7管道的运行方式的了解 我觉得x87或并没有什么uarch特定的东西,或者std::atomic动态分配没有额外的间接层。在Atom或K8上,它们也会变慢。仍在投票,但这就是为什么我拒绝您的一些建议。
彼得·科德斯

这些是公平点。无论如何,这些事情仍然在朝着问询者的目标努力。赞赏投票:)
Jesper Juhl

SSE单元使用端口0、1和
5。x87

@Michas:你错了。Haswell不在端口5上运行任何SSE FP数学指令。主要是SSE FP随机播放和布尔值(xorps / andps / orps)。x87的速度较慢,但​​您对原因的解释有些错误。(这是完全错误的。)
彼得·科德斯

1
@Michas:movapd xmm, xmm通常不需要执行端口(在IVB及更高版本上的寄存器重命名阶段处理)。在AVX代码中几乎也不需要它,因为除了FMA之外的所有东西都是非破坏性的。但是很公平,如果没有消除,Haswell在port5上运行它。我没有看过x87寄存器复制(fld st(i)),但是您适合使用Haswell / Broadwell:它运行在p01上。Skylake在p05上运行,SnB在p0上运行,IvB在p5上运行。因此,IVB / SKL在p5上做了一些x87的东西(包括比较),但SNB / HSW / BDW根本不使用x5的p5。
彼得·科德斯

11

您可以long double用于计算。在x86上,它应该是80位格式。仅传统的x87 FPU支持此功能。

x87 FPU的一些缺点:

  1. 缺少SIMD,可能需要更多说明。
  2. 基于堆栈,对于超标量和流水线架构存在问题。
  3. 单独的寄存器组非常小,可能需要与其他寄存器进行更多的转换以及更多的存储器操作。
  4. 在Core i7上,SSE有3个端口,x87只有2个端口,处理器可以执行较少的并行指令。

3
对于标量数学,x87数学指令本身仅稍慢一些。但是,存储/加载10字节操作数的速度要慢得多,而且x87的基于堆栈的设计往往需要额外的指令(例如fxch)。使用-ffast-math,好的编译器可以向量化monte-carlo循环,而x87可以防止这种情况。
彼得·科德斯

我扩大了我的答案。
米查斯(Michas)'16

1
回复:4:您在谈论哪个i7上位族,以及哪些指示?Haswell可以mulss在p01 fmul上运行,但只能在上运行p0addss只能运行在p1,一样的fadd。只有两个执行端口可以处理FP数学运算。(唯一的例外是Skylake删除了专用的add单元,并addss在p01上运行了FMA单元,但fadd在p5上运行。因此,通过fadd与一起混入一些指令fma...ps,理论上您可以做更多的FLOP / s。)
彼得·科德斯(Peter Cordes)

2
另请注意,Windows x86-64 ABI具有64位long double,即仍为double。SysV ABI确实使用80bit long double。同样,re:2:寄存器重命名在堆栈寄存器中公开了并行性。基于堆栈的体系结构需要一些额外的指令,例如fxchgesp。交错并行计算时。因此,更像是没有内存往返就很难表达并行性,而不是让uarch很难利用那里的东西。不过,您不需要来自其他法规的更多转换。不知道那是什么意思。
彼得·科德斯

6

答案较晚,但我认为我们没有足够地滥用链接列表和TLB。

使用mmap分配节点,这样您就可以主要使用地址的MSB。这将导致长的TLB查找链,页面为12位,剩下52位用于转换,或每次必须遍历5个级别。幸运的是,它们每次必须进入内存进行5级查找和1次内存访问才能到达您的节点,最有可能将顶层存储在某个地方的缓存中,因此我们可以希望进行5次内存访问。放置节点,使其跨度最差,以便读取下一个指针将导致另一个3-4转换查找。由于大量的翻译查找,这也可能完全破坏高速缓存。此外,虚拟表的大小可能会导致大多数用户数据被分页到磁盘上,这需要额外的时间。

从单个链接列表中读取时,请确保每次都从列表的开头进行读取,以最大程度地延迟读取单个数字。


对于48位虚拟地址,x86-64页表的深度为4级。(PTE具有52位物理地址)。未来的CPU将支持5级页面表功能,另外9位虚拟地址空间(57)。 为什么在64位中,虚拟地址比物理地址(52位长)短4位(长48位)?。操作系统默认不会启用它,因为它速度较慢并且不会带来任何好处,除非您需要那么多的虚拟地址空间。
彼得·科德斯

但是,是的,有趣的主意。您可以mmap在文件或共享内存区域上使用,以获取同一物理页面(具有相同内容)的多个虚拟地址,从而在相同数量的物理RAM上允许更多的TLB丢失。如果您的链表next只是一个相对偏移量,则您可能会在同一页面上进行一系列带有的映射,+4096 * 1024直到最终到达另一个物理页面为止。或者当然跨多个页面以避免L1d缓存命中。在页面漫游硬件中缓存了更高级别的PDE,因此可以在virt addr空间中传播它!
彼得·科德斯

在旧地址上增加偏移量还会使[使用[reg+small_offset]寻址模式的特殊情况] 失效(如果base + offset与基准页位于不同的页面中,会产生不利影响吗?);也会使负载使用延迟变得更糟。您将获得add64位偏移量的内存源,或者将获得加载和类似的索引寻址模式[reg+reg]。另请参阅L2 TLB丢失后会发生什么?-页面遍历通过SnB系列上的L1d缓存获取。
彼得·科德斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.