在Intel sandybridge系列cpu中对管道的程序进行反优化

为了完成这项作业,我已经绞尽脑汁一个星期了,我希望这里有人能指引我走上正确的道路。让我从教练的指导开始:

你的作业和我们第一个实验作业正好相反,第一个实验作业是优化一个质数程序。你在这项作业中的目的是使程序变得悲观,即使它运行得慢一些。这两个程序都是cpu密集型程序。它们需要几秒钟才能在我们的实验室电脑上运行。你不能改变算法。

要对程序进行反优化,请使用您对Intel i7管道如何运行的了解。想象一下重新排序指令路径以引入WAR、RAW和其他危险的方法。考虑一些方法来最小化缓存的有效性。极度无能。

作业要求在磨石或蒙特卡洛程序中进行选择。缓存效率注释大多只适用于Whetstone,但我选择了Monte-Carlo模拟程序:

// 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指令和如何确定缓存大小,以及intrinsic和CLFLUSH指令。
  • 任何编译器选项都是允许的,inline asm也是如此。
  • 编写自己的平方根算法被认为是不合理的

Cowmoogun对元线程的注释表明不清楚编译器优化可能是其中的一部分,并假设-O0,运行时增加17%是合理的。

听起来作业的目标是让学生重新排列现有的作业,以减少指令级的并行性或类似的事情,但这并不是一件坏事,人们钻研得更深入,学得更多。


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

48376 次浏览

重要的背景阅读:Agner Fog's microarch pdf,可能还有Ulrich Drepper的关于内存,每个程序员都应该知道的事。也可参阅标签wiki中的其他链接,特别是Intel的优化手册和David Kanter的Haswell微体系结构分析,并附有图表

非常酷的任务;比我所见过的学生被要求优化gcc -O0的一些代码要好得多,学习了一堆在实际代码中无关紧要的技巧。在这种情况下,要求您了解CPU管道,并使用它来指导反优化工作,而不仅仅是盲目的猜测。其中最有趣的部分是用“邪恶的无能”来证明每一种悲观,而不是故意的恶意。


作业的措辞和代码有问题:

这段代码的特定于uarch的选项是有限的。它不使用任何数组,大部分开销是调用exp/log库函数。没有一种明显的方法来获得或多或少的指令级并行性,并且循环携带的依赖链非常短。

仅仅通过重新安排表达式来改变依赖关系来减少独立的危害是很难得到减速的。

英特尔sandybridge系列cpu是激进的乱序设计,花费了大量的晶体管和功率来寻找并行性,并避免会困扰一个经典的RISC顺序管道的危险(依赖)。通常,唯一的传统危险会减慢它的速度是原始的“真实”。导致吞吐量受到延迟限制的依赖关系。

WAR和WAW危险对于寄存器来说几乎不是一个问题,多亏了寄存器重命名。(除了popcnt/lzcnt/tzcnt,它们有错误地依赖于英特尔cpu,即使它应该只写)。

对于内存排序,现代cpu使用存储缓冲区延迟提交到缓存,直到退役,也避免了WAR和WAW的危险。请参见这个答案,了解什么是存储缓冲区,以及OoO exec将执行与其他核心可以看到的东西分离的必要条件。

为什么mulss在Haswell上只需要3个周期,不同于Agner's指令表?(用多个蓄能器展开FP循环)有更多关于寄存器重命名和隐藏FMA延迟在FP点积循环。


“i7"品牌名称与Nehalem (Core2的继承者)一起引入,一些英特尔手册甚至说Core i7,当他们似乎是指Nehalem时,但他们保留了&;i7&;品牌对于Sandybridge和后来的微架构。SnB是指p6家族进化成一个新的物种,SnB家族。在许多方面,Nehalem与Pentium III有更多的共同点,而不是Sandybridge(例如,在SnB上不会发生寄存器读取暂停,因为它改为使用物理寄存器文件。还有一个uop缓存和不同的内部uop格式)。术语“i7 architecture”;没有用,因为将snb家族与Nehalem组在一起而不是Core2组在一起没有什么意义。(不过,Nehalem确实引入了用于连接多个核心的共享包容性L3缓存架构。还有集成的图形处理器。因此,在芯片级别,命名更有意义。)


恶魔般的无能可以证明的好想法的总结

即使是极其不称职的人也不太可能添加明显无用的工作或无限循环,而把c++ /Boost类搞得一团糟也超出了任务的范围。

  • 具有单个共享 std::atomic<uint64_t>循环计数器的多线程,因此发生正确的迭代总数。原子uint64_t在-m32 -march=i586中特别糟糕。为了获得额外的分数,安排它不对齐,并通过一个不均匀的分割页面边界(不是4:4)。
  • 错误分享用于其他非原子变量->内存顺序错误推测管道清除,以及额外的缓存丢失。
  • 而不是在FP变量上使用-,而是用0x80对高字节进行异或来翻转符号位,导致store-forwarding摊位. 0。
  • 对每个迭代单独计时,使用比RDTSC更重的东西。例如CPUID / RDTSC或一个时间函数,它进行系统调用。序列化指令本质上是管道不友好的。
  • 变化乘以常数,除以常数的倒数(“为了便于阅读”)。Div很慢,而且没有完全流水线化。
  • 使用AVX (SIMD)对乘/根号向量化,但在调用标量数学库exp()log()函数之前未能使用vzeroupper,导致SSE过渡失速
  • 将RNG输出存储在链表中,或者存储在无序遍历的数组中。每次迭代的结果都是一样的,最后求和。

在这个答案中还包括,但在总结中不包括:在非流水线CPU上同样缓慢的建议,或者即使是恶魔般的无能也似乎不合理。例如,许多蹩脚的编译器想法会产生明显不同/更糟糕的asm。


多线程严重

也许可以使用OpenMP以很少的迭代实现多线程循环,开销远远大于速度增益。你的蒙特卡罗代码有足够的并行度来实现加速,特别是如果我们成功地使每次迭代变慢的话。(每个线程计算一个部分payoff_sum,添加在最后)。循环中的#omp parallel可能是一个优化,而不是一个悲观。

这似乎很符合逻辑。这意味着使用static变量作为循环计数器。这证明了使用atomic作为循环计数器是正确的,并创建了实际的高速缓存线路次涨跌(只要线程不运行在与超线程相同的物理内核上;这可能不是static0慢)。不管怎样,这个static1比lock xaddlock dec的非争用情况要慢。并且,在32位系统上,lock cmpxchg8b要对争用的uint64_t进行原子增量,必须在循环中重试,而不是让硬件仲裁一个原子inc

还可以创建错误分享,其中多个线程将它们的私有数据(例如RNG状态)保存在同一缓存线的不同字节中。(关于它的英特尔教程,包括性能计数器看)这有一个微架构特定的方面:英特尔cpu推测内存无序排序发生,有一个至少在P4上使用memory order机器清除性能事件来检测这个问题。对哈斯威尔的惩罚可能没有那么大。正如该链接所指出的,locked指令假设会发生这种情况,以避免错误推测。正常的加载推测其他内核不会在加载执行和程序顺序(除非你使用pause)退出之间使缓存行失效。没有locked指令的真正共享通常是一个错误。比较非原子共享循环计数器和原子情况会很有趣。为了实现真正的悲观,保留共享的原子循环计数器,并在相同或不同的缓存线中为其他变量造成虚假共享。


随机uarch特定的想法:

如果你能引入任何不可预测的分支,这将大大降低代码。现代x86 cpu有相当长的管道,所以一个错误的预测花费大约15个周期(当从uop缓存运行时)。


依赖链:

我认为这是作业的一部分。

通过选择具有一个较长的依赖链而不是多个较短的依赖链的操作顺序来克服CPU利用指令级并行的能力。编译器不允许改变FP计算的操作顺序,除非你使用-ffast-math,因为这会改变结果(如下所述)。

为了真正有效,增加循环携带的依赖链的长度。不过,没有什么是显而易见的:所编写的循环具有非常短的循环携带依赖链:只有一个FP add.(3个循环)。多个迭代可以同时进行计算,因为它们可以在前一个迭代结束的payoff_sum +=之前开始。(log()exp需要很多指令,但并不比Haswell用于寻找并行性的乱序窗口:ROB size=192个融合域uops,调度器size=60个非融合域uops多很多。一旦当前迭代的执行进展到足以为下一次迭代的指令腾出空间,它的任何部分已经准备好了输入(即独立/独立的dep链),就可以开始执行,而旧的指令让执行单元空闲(例如,因为它们在延迟上遇到瓶颈,而不是吞吐量)。

RNG状态几乎肯定是一个比addps更长的循环携带依赖链。


使用更慢/更多的FP操作(特别是更多的除法):

除以2.0而不是乘以0.5,以此类推。FP乘法在英特尔的设计中被大量流水线化,并且在Haswell和以后的产品中每0.5c的吞吐量有一个。FP __ABC0/divpd只是部分流水线。(尽管Skylake在divpd xmm中有一个令人印象深刻的每4c的吞吐量,有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)。注意,虽然对实数的数学运算是结合的,浮点数学是而不是,即使不考虑overflow/NaN(这就是-ffast-math默认不开启的原因)。参见保罗的评论获得一个非常复杂的嵌套pow()建议。

如果你可以将计算缩小到非常小的数字,那么FP数学运算将使用当对两个正数的操作产生一个非正数时,要捕获到微码的120个额外周期。具体数字和细节请参见Agner Fog的microarch pdf。这是不可能的,因为你有很多乘数,所以比例因子会是平方,一直下溢到0.0。我看不出有任何方法可以证明这种必要的扩张是无能的(甚至是邪恶的),只有故意的恶意。


###如果你可以使用intrinsic (<immintrin.h>)

使用movnti从缓存中删除数据。恶魔:它是新的,弱顺序的,所以应该让CPU运行得更快,对吗?或者看看这个链接的问题,在这种情况下,某人正处于这样做的危险中(对于只有一些位置是热点的分散写入)。clflush可能是不可能没有恶意。

在FP数学运算之间使用整数洗牌来引起旁路延迟。

混合SSE和AVX指令而不正确使用vzeroupper会导致pre-Skylake中的大摊位(和不同的惩罚在Skylake)。即使没有这些,糟糕的向量化也可能比标量化更糟糕(将数据移进/移出向量所花费的周期,比一次进行4次蒙特卡洛迭代(256b个向量)的添加/订阅/mul/div/sqrt操作所节省的周期还要多)。add/sub/mul执行单元是完全管道的和全宽度的,但是256b向量上的div和sqrt没有128b向量(或标量)上的快,所以double的加速不是很明显。

exp()log()没有硬件支持,因此这部分需要将向量元素提取回标量并单独调用库函数,然后将结果变换回向量。libm通常被编译为只使用SSE2,因此将使用标量数学指令的遗留- sse编码。如果你的代码使用256b向量并调用exp而没有先执行vzeroupper,那么你就会暂停。返回后,像vmovsd这样的AVX-128指令将下一个vector元素设置为exp的参数也会暂停。然后exp()在运行SSE指令时会再次失速。这正是这个问题中发生的 ,导致10倍的减速。(感谢@ZBoson)。

另见Nathan Kurz用Intel的数学lib和glibc的实验。未来的glibc将附带exp()的向量化实现等等。


如果目标是pre-IvB,或Nehalem,尝试让gcc用16位或8位操作导致部分寄存器停顿,然后是32位或64位操作。在大多数情况下,gcc将在8位或16位操作后使用movzx,但是这里是gcc修改__ABC1然后读取ax的情况 . c


使用(内联)asm:

使用(内联)asm,您可以破坏uop缓存:一个32B的代码块,不适合3个6uop缓存行,强制从uop缓存切换到解码器。一个不称职的ALIGN(就像NASM的默认值一样)在内部循环的分支目标上使用许多单字节的__abc1而不是一对长__abc1可能会达到目的。或者将对齐填充放在标签之后,而不是之前。这只在前端是瓶颈的情况下才有关系,如果我们成功地对其余代码进行了悲观处理,就不会有问题了。

使用自修改代码来触发管道清除(又名machine-nukes)。

连结控制协定摊位来自16位指令,立即太大,不适合8位是不太可能有用的。SnB和以后的uop缓存意味着你只需要支付解码惩罚一次。在Nehalem(第一个i7)上,它可能适用于一个不适合28 uop循环缓冲区的循环。gcc有时会生成这样的指令,即使使用-mtune=intel并且当它可以使用32位指令时也是如此。


一个常见的计时习语是__ABC0(序列化)然后RDTSC。每次迭代都分别使用CPUID/RDTSC计时,以确保RDTSC没有与前面的指令重新排序,这会减慢很多的速度。(在现实生活中,计算时间的聪明方法是一起计算所有迭代的时间,而不是分别计算每个迭代的时间并将它们相加)。


导致大量缓存丢失和其他内存减慢

对某些变量使用union { double d; char a[8]; }导致存储转发中断 .通过对其中一个字节进行窄存储(或Read-Modify-Write)来实现。(那篇wiki文章还涵盖了许多加载/存储队列的其他微架构内容)。例如,在高字节上使用XOR 0x80翻转double的符号,而不是-操作符。糟糕透顶的开发人员可能听说FP比整数慢,因此尽量使用整数运算。(理论上,编译器仍然可以将其编译为带有像-这样的常量的xorps,但对于x87,编译器必须意识到它正在对值和fchs进行否定,或者将下一个加法替换为减法。)


如果使用-O3编译而不使用std::atomic,则使用volatile强制编译器实际在所有地方存储/重载。全局变量(而不是局部变量)也会强制一些存储/重新加载,但c++内存模型的弱排序不需要编译器一直溢出/重新加载到内存中。

用大结构体的成员替换局部变量,这样就可以控制内存布局。

在结构体中使用数组填充(并存储随机数,以证明它们的存在)。

选择你的内存布局,所以在同一个“set”中,所有的东西都进入了不同的行;在L1缓存中。它只有8种联想,即每个集合有8种“方式”。缓存线是64B。

更好的是将4096B完全分开,因为加载对存储到不同页面但在页面内具有相同偏移量的页面有错误依赖。严重的乱序cpu使用内存消歧,在不改变结果的情况下确定加载和存储何时可以重新排序,并且Intel的实现具有假阳性,可以防止过早开始负载。它们可能只检查页偏移量以下的位,以便在TLB将高位从虚拟页转换到物理页之前启动。和Agner的指南一样,请参见这个答案,以及@Krazy Glew对同一问题的回答的末尾部分。(Andy Glew是英特尔PPro - P6微架构的架构师)(也相关:https://stackoverflow.com/a/53330296https://github.com/travisdowns/uarch-bench/wiki/Memory-Disambiguation-on-Skylake)

使用__attribute__((packed))可以让你错误地对齐变量,使它们跨越缓存线甚至页面边界。(因此加载一个double需要来自两条缓存线的数据)。在任何Intel i7 uarch中,不对齐的加载都没有惩罚,除非跨越缓存线和页线。缓存线分割仍然需要额外的周期。Skylake极大地降低了页面拆分加载的惩罚,从100到5个周期。(2.1.3节)。(并且可以并行地进行两页遍历)。

atomic<uint64_t>上的页面分割应该是最坏的情况,特别是如果它在一个页面中是5个字节,在另一个页面中是3个字节,或者不是4:4。即使是中间的分割对于某些uarch上的16B向量的缓存线分割也更有效,IIRC。将所有内容放在alignas(4096) struct __attribute((packed))中(当然是为了节省空间),包括用于存储RNG结果的数组。通过在计数器之前使用uint8_tuint16_t来实现不对中。

如果你能让编译器使用索引寻址模式,那将击败uop微融合。也许可以使用#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所建议的那样,在内循环中遍历行,而不是在行中的列)。如果恶魔般的无能可以证明2D数组具有这样的维度,那么普通的现实世界的无能很容易证明使用错误的访问模式的循环是正确的。这发生在现实生活中的真实代码中。

如果数组不是那么大,如果需要使用许多不同的页面,而不是重用相同的几个页面,可以调整循环边界。硬件预取不能跨页面(也不能/根本不能)工作。预取器可以在每个页面中跟踪一个正向流和一个反向流(就像这里发生的那样),但只有在内存带宽还没有被非预取饱和时才会对它起作用。

这也会产生很多TLB错误,除非页面合并成一个hugepage (Linux对使用__ABC2的__ABC0/new之类的匿名(不支持文件的)分配机会性地做到了这一点)。

你可以使用链表代替数组来存储结果列表。每次迭代都需要一个指针跟踪负载(对于下一个负载的负载地址来说,这是一个RAW真正的依赖风险)。使用糟糕的分配器,您可能会设法将列表节点分散在内存中,从而破坏缓存。如果使用糟糕的分配器,它可能会将每个节点放在自己页面的开头。(例如,直接使用mmap(MAP_ANONYMOUS)进行分配,而不需要分割页面或跟踪对象大小来正确支持free)。


这些并不是特定于微体系结构的,也与管道没有什么关系(其中大多数也会在非管道的CPU上减慢)。

有点跑题:让编译器生成更糟糕的代码/做更多的工作:

对于最悲观的代码,使用c++ 11 std::atomic<int>std::atomic<double>。MFENCEs和locked指令非常慢,即使没有来自另一个线程的争用。

-m32将使代码变慢,因为x87代码将比SSE2代码更差。基于堆栈的32位调用约定需要更多指令,甚至将堆栈上的FP参数传递给exp()这样的函数。__ABC0上的__ABC2需要一个lock cmpxchg8B循环 (i586)。(所以使用循环计数器!(邪恶的笑))。

-march=i386也会悲观(谢谢@Jesper)。FP与fcom相比比686 fcomi慢。Pre-586不提供原子的64位存储(更不用说cmpxchg),因此所有64位atomic操作都编译为libgcc函数调用(这可能是为i686编译的,而不是实际使用锁)。在最后一段的Godbolt Compiler Explorer链接中尝试一下。

使用long double / sqrtl / expl在sizeof(long double)为10或16(带有用于对齐的填充)的abi中获得额外的精度和额外的速度。(IIRC, 64bit Windows使用相当于double的8byte long double。(无论如何,10byte (80bit) FP操作数的加载/存储是4 / 7 uop,而floatdouble对于fld m64/m32/fst只需要1 uop)。用long double强制x87会失败自动向量化,即使是gcc sqrtl1。

如果不使用atomic<uint64_t>循环计数器,则所有内容都使用long double,包括循环计数器。

atomic<double>编译,但它不支持像+=这样的读取-修改-写入操作(即使在64位上)。atomic<long double>必须为原子加载/存储调用库函数。它可能真的很低效,因为x86 ISA不支持原子的10字节加载/存储,而且我能想到的唯一不锁定的方法(cmpxchg16b)需要64位模式。


-O0中,通过将部分分配给临时变量来分解一个大表达式将导致更多的存储/重新加载。如果没有volatile之类的东西,这与实际代码的实际构建所使用的优化设置无关。

C语言的混叠规则允许char对任何东西进行混叠,因此通过char*进行存储会迫使编译器存储/重新加载字节存储之前/之后的所有内容,即使是在-O3。(例如,这是自动向量化操作uint8_t数组的代码的一个问题。)

尝试uint16_t循环计数器,强制截断为16位,可能通过使用16位操作数-size(潜在的停顿)和/或额外的movzx指令(安全)。有符号溢出是未定义的行为,所以除非你使用-fwrapv或至少-fno-strict-overflow签名循环计数器不必每次迭代都重新签名扩展,即使用作64位指针的偏移量。


强制从整数转换为float并返回。和/或double<=>float转换。指令有延迟>float (cvtsi2ss)的设计很糟糕,不会将xmm寄存器的其余部分归零。(出于这个原因,gcc插入了一个额外的pxor来打破依赖关系。)


通常是将您的CPU亲和性设置为不同的CPU(由@Egwor建议)。恶魔推理:你不希望一个核心因为长时间运行线程而过热,对吧?也许换到另一个核心会让核心涡轮增压到更高的时钟速度。(事实上:它们彼此的热距离非常近,这是极不可能的,除非在多插座系统中)。现在只是调错了,并且经常这样做。除了花费在操作系统保存/恢复线程状态上的时间外,新内核还具有冷的L2/L1缓存、uop缓存和分支预测器。

频繁引入不必要的系统调用会降低您的速度,无论它们是什么。尽管一些重要但简单的函数(如gettimeofday)可以在用户空间with中实现,而无需过渡到内核模式。(Linux上的glibc在内核的帮助下做到了这一点:内核在VDSO中导出代码+数据)。

关于系统调用开销的更多信息(包括返回用户空间后的缓存/TLB错误,而不仅仅是上下文切换本身),FlexSC纸对当前情况有一些很好的性能计数器分析,以及从大规模多线程服务器进程批量处理系统调用的建议。

你可以做一些事情来让事情尽可能地糟糕:

  • 编译i386架构的代码。这将防止使用SSE和更新的指令,并强制使用x87 FPU。

  • 在任何地方使用std::atomic变量。这将使它们非常昂贵,因为编译器被迫在所有地方插入内存屏障。这是一个不称职的人为了“确保线程安全”可能会做的事情。

  • 确保以预取器预测的最糟糕的方式访问内存(列主序与行主序)。

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

  • 确保您分配的所有内存都是非常奇怪的对齐方式,并且无论如何都要避免分配巨大的页面,因为这样做会大大提高TLB的效率。

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

注意:这个回答基本上只是总结了我的评论,@Peter Cordes已经把他的回答纳入了他非常好的回答中。如果你只有一张票,建议你给他点赞:)

你可以使用long double进行计算。在x86上,它应该是80位格式。只有传统的x87 FPU支持这个功能。

x87 FPU的几个缺点:

  1. 缺少SIMD,可能需要更多的说明。
  2. 基于堆栈的,对于超标量和流水线架构是有问题的。
  3. 独立且相当小的寄存器集,可能需要从其他寄存器进行更多的转换和更多的内存操作。
  4. 在酷睿i7上,SSE有3个端口,x87只有2个端口,处理器可以执行更少的并行指令。

回答得晚了,但我觉得我们对链表和TLB的滥用还不够。

使用mmap来分配您的节点,以便您主要使用地址的MSB。这应该会导致很长的TLB查找链,一个页面是12位,剩下52位用于翻译,或者每次必须遍历大约5级。如果运气好的话,它们每次都必须到内存中进行5级查找和1次内存访问才能到达你的节点,顶层最有可能在缓存的某个地方,所以我们可以期望5*内存访问。放置节点,使其跨越最差的边界,以便读取下一个指针将导致另外3-4次转换查找。由于大量的翻译查找,这也可能完全破坏缓存。此外,虚拟表的大小可能会导致大多数用户数据被分页到磁盘上以延长时间。

从单链表读取时,请确保每次从链表的开头读取,以在读取单个数字时造成最大延迟。