Deoptimizing a program for the pipeline in Intel Sandybridge-family CPUs
为了完成这项任务,我绞尽脑汁已经一个星期了,我希望这里有人能引导我走上正确的道路。让我从讲师的指导开始:
Your assignment is the opposite of our first lab assignment, which was to optimize a prime number program. Your purpose in this assignment is to pessimize the program, i.e. make it run slower. Both of these are CPU-intensive programs. They take a few seconds to run on our lab PCs. You may not change the algorithm.
To deoptimize the program, use your knowledge of how the Intel i7 pipeline operates. Imagine ways to re-order instruction paths to introduce WAR, RAW, and other hazards. Think of ways to minimize the effectiveness of the cache. Be diabolically incompetent.
这项任务让我们可以选择Whetstone或Monte Carlo程序。缓存有效性注释大多只适用于Whetstone,但我选择了蒙特卡洛模拟程序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 | // Un-modified baseline for pessimization, as given in the assignment #include // 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; } |
我所做的更改似乎将代码运行时间延长了一秒钟,但我不完全确定在不添加代码的情况下可以更改什么来暂停管道。一个指向正确方向的点是非常棒的,我很感激任何回应。
更新:给这项任务的教授发布了一些细节重点是:
- 这是社区学院第二学期的建筑课程(使用亨尼西和帕特森的教科书)。
- 实验室的计算机有很好的CPU
- 学生们已经接触了
CPUID 指令,以及如何确定缓存大小,以及intrinsics和CLFLUSH 指令。 - 允许使用任何编译器选项,内联asm也是如此。
- 写你自己的平方根算法被宣布是在苍白之外
Cowmoogun对meta线程的评论表明编译器优化可能是其中的一部分,这并不清楚,并且假设
所以这听起来像是任务的目标是让学生重新排序现有的工作,以减少指令级的并行性或类似的事情,但这不是一件坏事,人们已经深入钻研和学习了更多。
请记住,这是一个计算机体系结构的问题,而不是一个关于如何使C++慢下来的问题。
重要的背景阅读:Agner Fog的Microarch PDF,也可能是每个程序员都应该知道的关于内存的Ulrich Drepper。另请参见x86标签wiki中的其他链接,尤其是英特尔的优化手册,以及DavidKanter对Haswell微体系结构的分析,以及图表。好的。
非常酷的作业;比我以前看到的那些要求学生为
作业措辞和代码有问题:好的。
此代码的UARCH特定选项是有限的。它不使用任何数组,而且大部分成本是调用
我希望看到这样一个答案:试图通过重新安排表达式来更改依赖项,减少依赖项(危险)带来的ILP,从而减缓速度。我没试过。好的。
Intel SandyBridge系列CPU是一款极具侵略性的无序设计,它使用大量的晶体管和电源来查找并行性,并避免危害(依赖性),而这些危害(依赖性)会给经典的RISC有序流水线带来麻烦。通常只有原始的"真正的"依赖性才是降低速度的传统风险,这些依赖性会导致吞吐量受到延迟的限制。好的。
由于寄存器的重命名,对寄存器的战争和waw危害几乎不是问题。(除
为什么muls在haswell上只需要3个周期,不同于agner的指令表?在fp点产品循环中有更多关于寄存器重命名和隐藏fma延迟的信息。好的。
"i7"品牌是由nehalem(core2的继承者)引入的,一些英特尔手册甚至在它们似乎是nehalem时说"corei7",但它们保留了sandybridge和后来的微体系结构的"i7"品牌。snb是当p6家族进化成一个新物种时,snb家族。在许多方面,Nehalem与Pentium III的共同点比SandyBridge的多(例如,寄存器读取暂停和rob读取暂停不会发生在snb上,因为它改为使用物理寄存器文件)。还有一个UOP缓存和一个不同的内部UOP格式)。术语"i7体系结构"并不有用,因为用nehalem而不是core2将snb家族分组是没有意义的。尽管如此,Nehalem还是引入了用于将多个核心连接在一起的共享包容性L3缓存体系结构。以及集成的GPU。所以芯片级别,命名更合理。)好的。总结那些邪恶的无能可以证明的好想法
即使是恶魔般的无能也不可能增加明显的无用的工作或无限循环,而使C++/Boost类的混乱超出了任务的范围。好的。
- 多线程,带有一个共享的
std::atomic 循环计数器,因此会发生正确的迭代总数。原子uint64_t对-m32 -march=i586 特别不利。对于奖励点数,请安排其不对齐,并使用不均匀的拆分(不是4:4)跨越页面边界。 - 其他一些非原子变量的错误共享->内存顺序错误推测管道清除,以及额外的缓存未命中。
- XOR不是在fp变量上使用
- ,而是在高位字节使用0x80来翻转符号位,从而导致存储转发暂停。 - 单独计时每次迭代,甚至比
RDTSC 更重的东西。例如,CPUID /RDTSC 或发出系统调用的时间函数。串行化指令天生就不友好。 - 变化乘以常数除以它们的倒数("便于阅读")。DIV速度慢,没有完全安装管道。
- 使用avx(simd)对multiple/sqrt进行矢量化,但在调用标量数学库
exp() 和log() 函数之前未能使用vzeroupper ,导致avx<->sse转换暂停。 - 将RNG输出存储在一个链接列表中,或者存储在您无序遍历的数组中。每次迭代的结果都一样,最后求和。
这个答案也包含了,但不包括在总结中:建议在非流水线的CPU上同样慢,或者即使在恶魔般的无能下也似乎不合理。例如,许多gimp编译器的思想会产生明显不同/更差的asm。好的。多线程严重
也许可以使用OpenMP来多线程循环,迭代次数很少,开销远大于速度增益。不过,您的蒙特卡洛代码有足够的并行性来实际加速,特别是如果我们成功地使每个迭代变慢的话。(每个线程计算一个在末尾添加的部分
多线程,但强制两个线程共享相同的循环计数器(使用
还创建假共享,其中多个线程将其私有数据(例如RNG状态)保存在同一缓存线的不同字节中。(有关它的英特尔教程,包括要查看的性能计数器)。这有一个特定于微体系结构的方面:英特尔CPU推测不会发生内存顺序错误,并且有一个内存顺序机器清除性能事件来检测这一点,至少在P4上。对哈斯韦尔的惩罚可能没有那么大。正如链接指出的那样,
如果您可以引入任何不可预知的分支,这将严重地困扰代码。现代的x86 CPU有相当长的管道,因此预测失误需要大约15个周期(从UOP缓存运行时)。好的。依赖链:
我认为这是任务的一部分。好的。
通过选择具有一个长依赖链而不是多个短依赖链的操作顺序来破坏CPU利用指令级并行性的能力。除非使用
要真正使其有效,请增加循环携带的依赖链的长度。不过,没有什么比这更明显的了:所编写的循环有非常短的循环携带依赖链:只是一个fp加法。(3个周期)。多个迭代可以同时进行计算,因为它们可以在前一个迭代结束时的
RNG状态几乎肯定会比
除以2.0而不是乘以0.5,依此类推。fp multiply在Intel设计中采用了大量流水线,在Haswell和更高版本上每0.5C有一个吞吐量。FP-
正如@paul clayton所建议的,用关联/分布等价物重写表达式可以引入更多的工作(只要不使用
如果您可以将计算缩小到非常小的数字,那么当对两个正常数字的操作产生非正常值时,fp math ops需要大约120个额外的周期来陷阱到微码。有关确切的数字和详细信息,请参阅Agner Fog的Microarch PDF。这是不太可能的,因为你有很多乘数,所以比例因子将被平方和下溢一直到0.0。我看不出任何方法可以用无能(甚至是恶毒的)来证明必要的规模扩张是故意的。好的。如果您可以使用intrinsics(
使用
在fp数学运算之间使用整数随机移动以导致绕过延迟。好的。
在没有正确使用
另请参阅Nathan Kurz对Intel的Math Lib与Glibc的实验,了解此代码。未来的glibc将提供
如果目标是IVB前,特别是Nehalem,请尝试让GCC通过16位或8位操作导致部分寄存器暂停,然后再进行32位或64位操作。在大多数情况下,GCC会在8位或16位操作后使用
使用(内联)asm,您可以断开UOP缓存:一个32B的代码块不适合三个6UOP缓存行,它强制从UOP缓存切换到解码器。一个不称职的
使用自我修改代码触发管道清除(即机器核武器)。好的。
16位指令的LCP暂停,且立即数太大,无法容纳8位,因此不太可能有用。SNB和以后的UOP缓存意味着您只需支付一次解码惩罚。在Nehalem(第一个i7)上,它可能适用于不适合28uop循环缓冲区的循环。GCC有时会生成这样的指令,即使使用
定时的一个常见习惯用法是EDOCX1(序列化),然后是EDOCX1(序列化)。用
对某些变量使用
如果使用
用一个大结构的成员替换本地变量,这样就可以控制内存布局。好的。
在结构中使用数组进行填充(并存储随机数,以证明它们的存在)。好的。
选择内存布局,使所有内容都进入一级缓存中相同"集合"中的不同行。它只有8路关联,即每个集合有8路。缓存线为64B。好的。
更好的是,将内容精确地分开4096b,因为加载对不同页面的存储有错误的依赖关系,但在一个页面内具有相同的偏移量。激进的无序CPU使用内存消除来确定什么时候可以在不改变结果的情况下对加载和存储进行重新排序,而Intel的实现具有错误的积极性,可以防止加载过早启动。可能它们只检查页面偏移量以下的位,因此可以在TLB将高位从虚拟页面转换为物理页面之前开始检查。除了《阿格纳指南》,还可以看到斯蒂芬·卡农的答案,以及@krazy glew关于同一问题的答案末尾的一节。(Andy Glew是英特尔最初的p6微体系结构的架构师之一。)好的。
使用
如果你能让编译器使用索引寻址模式,那将击败UOP微融合。可以用
如果您可以引入一个额外级别的间接寻址,那么加载/存储地址就不早知道了,这可能会进一步困扰您。好的。以非连续顺序遍历数组
我认为我们可以首先提出引入数组的不充分理由:它允许我们将随机数生成与随机数使用分开。每一次迭代的结果也可以存储在一个数组中,稍后再进行总结(具有更多的恶魔般的无能)。好的。
对于"最大随机性",我们可以让一个线程在随机数组上循环,向其中写入新的随机数。使用随机数的线程可以生成随机索引以从中加载随机数。(这里有一些工作,但在微体系结构上,它有助于尽早知道加载地址,以便在需要加载的数据之前解决任何可能的加载延迟。)在不同的核心上拥有读写器将导致内存顺序错误推测管道清除(前面为错误共享案例讨论过)。好的。
为了实现最大的混乱,请以4096字节的步幅(即512倍)循环您的数组。例如好的。
1 2 3 | 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,…好的。
这是访问二维数组(如
如果数组不太大,则可以根据需要调整循环边界,以使用多个不同的页,而不是重复使用相同的几页。硬件预取在整个页面上都不起作用。预取器可以跟踪每个页面中的一个前向流和一个后向流(这就是这里所发生的情况),但仅当内存带宽尚未被非预取饱和时才会对其执行操作。好的。
这也会产生大量的TLB遗漏,除非页面合并成一个大页面(Linux为匿名(不支持文件)分配(如
您可以使用链接列表来代替存储结果列表的数组。然后,每个迭代都需要一个指针跟踪负载(对于下一个负载的负载地址来说,这是一个原始的、真实的依赖性风险)。使用一个坏的分配器,您可能会设法分散内存中的列表节点,从而破坏缓存。使用一个非常不称职的分配器,它可以将每个节点放在自己页面的开头。(例如,直接与
这些并不是特定于微体系结构的,与管道几乎没有关系(其中大部分还可能是非流水线CPU的速度减慢)。好的。稍微偏离主题:使编译器生成更糟糕的代码/做更多的工作:
对于最悲观的代码,使用C++ 11 EDCOX1、6和EDOCX1 7。mfences和
使用
/好的。
如果不使用
/好的。
/好的。
在
/好的。
C混叠的规则允许的任何一
/好的。
试着
/好的。
力的变换从整数到
/好的。
你的CPU的仿射竞相集到一个不同的CPU(suggested主编的"egwor)。diabolical推理:你不想要一个核to get overheated从运行你的线程,一个长的时间,你呢?也许swapping到另一个核,核汽轮会让我们的更高的时钟速度。(在现实:他们是thermally SO CLOSE到每一其他,这是高unlikely除了在一个多端口系统)。现在我得到的错误校正和做通太常常准备。besides的时间都在操作系统的节电/恢复线程状态,新的核有冷的L2和L1 caches UOP,高速缓存,分支和predictors。。。。。。。
/好的。
频繁地引入不必要的系统调用,无论它们是什么,都会减慢您的速度。虽然有些重要但简单的方法,如
对于更多的系统调用开销(包括返回到用户空间后的缓存/TLB未命中,而不仅仅是上下文切换本身),flexsc论文对当前情况进行了一些很好的性能计数器分析,并提出了对来自大量多线程服务器进程的系统调用进行批处理的建议。好的。好啊。
你可以做一些事情使事情尽可能的糟糕:
编译i386体系结构的代码。这将阻止使用SSE和更新的指令,并强制使用X87 FPU。
在任何地方都使用
std::atomic 变量。这将使它们非常昂贵,因为编译器被迫在各处插入内存屏障。这是一个不称职的人为"确保线程安全"可能会做的事情。确保以最差的方式访问内存,以便预取器进行预测(列主控与行主控)。
为了使变量变得更加昂贵,您可以通过使用
new 分配变量来确保它们都具有"动态存储持续时间"(已分配堆),而不是让它们具有"自动存储持续时间"(已分配堆栈)。确保您分配的所有内存都是非常奇怪的对齐方式,并且无论如何都要避免分配巨大的页面,因为这样做会非常高效。
无论您做什么,都不要在启用编译器优化器的情况下构建代码。并确保启用最具表现力的调试符号(不会使代码运行速度变慢,但会浪费一些额外的磁盘空间)。
注意:这个答案基本上只是总结了我的评论,@peter cordes已经融入了他的好答案中。如果你只有一张备用票,建议他投你的赞成票。)
您可以使用
X87 FPU的几个缺点:
回答晚了,但我觉得我们滥用链接列表和TLB还不够。
使用mmap来分配您的节点,这样您就主要使用地址的msb。这将导致长的TLB查找链,一个页面是12位,留下52位用于翻译,或者每次必须遍历5个级别。幸运的是,它们每次都必须进入内存进行5级查找,再加上1个内存访问才能到达您的节点,顶层很可能位于缓存中的某个位置,因此我们希望能够访问5*内存。放置节点,使其跨过最差的边界,以便读取下一个指针将导致另一个3-4个翻译查找。由于大量的翻译查找,这也可能完全破坏缓存。另外,虚拟表的大小可能会导致大多数用户数据被分页到磁盘上以获得额外的时间。
从单个链接列表中读取时,请确保每次都从列表的开头读取,以使读取单个数字的最大延迟。