取消优化英特尔 Sandybridge 系列 CPU 中流水线的程序
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 程序的选择。 cache-effectiveness评论大多只适用于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;
}
我所做的更改似乎使代码 运行ning 时间增加了一秒钟,但我不完全确定我可以更改什么来在不添加代码的情况下停止管道。指出正确的方向会很棒,我很感激任何回复。
更新:the professor who gave this assignment posted some details
重点是:
- 这是社区学院第二学期的建筑class(使用 Hennessy 和 Patterson 教科书)。
- 实验室计算机有 Haswell CPU
- 同学们已经了解了
CPUID
指令和如何确定缓存大小,以及内部函数和CLFLUSH
指令。
- 允许任何编译器选项,内联 asm 也是如此。
- 编写您自己的平方根算法被宣布为过时
Cowmoogun 在元线程上的评论表明 it wasn't clear compiler optimizations could be part of this, and assumed -O0
,运行 时间增加 17% 是合理的。
所以听起来作业的目标是让学生重新排序现有的工作以减少指令级并行性或类似的东西,但人们更深入地研究并学到更多并不是一件坏事.
请记住,这是一个计算机体系结构问题,而不是关于如何使 C++ 通常变慢的问题。
重要背景阅读:Agner Fog's microarch pdf, and probably also Ulrich Drepper's What Every Programmer Should Know About Memory. See also the other links in the x86 tag wiki, especially Intel's optimization manuals, and David Kanter's analysis of the Haswell microarchitecture, with diagrams.
非常酷的作业;比我在 看到的那些要好得多,学习了一堆在实际代码中无关紧要的技巧。在这种情况下,系统会要求您了解 CPU 管道并使用它来指导您的 de-optimization 工作,而不仅仅是盲目猜测。 这个最有趣的部分是用“恶魔般的无能”而不是故意的恶意来证明每一次悲观。
作业措辞和代码有问题:
此代码的 uarch-specific 选项有限。它不使用任何数组,大部分成本是对 exp
/log
库函数的调用。没有明显的方法来获得或多或少的 instruction-level 并行性,并且 loop-carried 依赖链非常短。
很难仅通过 re-arranging 表达式来降低依赖性,以减少 ILP 的危害。
英特尔 Sandybridge-family CPU 是激进的 out-of-order 设计,它花费大量的晶体管和功率来寻找并行性并避免会给 a classic RISC in-order pipeline 带来麻烦的危险(依赖性)。通常唯一会减慢速度的传统危害是 RAW“真实”依赖性,它会导致吞吐量受到延迟的限制。
WAR and WAW hazards for registers are pretty much not an issue, thanks to register renaming. (except for popcnt
/lzcnt
/tzcnt
, which have a false dependency their destination on Intel CPUs,虽然应该是write-only).
对于内存排序,现代 CPUs 使用 store buffer to delay commit into cache until retirement, also avoiding WAR and WAW hazards. See also this answer 关于什么是存储缓冲区,并且对于 OoO exec 将执行与其他内核可以看到的东西分离是必不可少的。
详细介绍了在 FP 点积循环中重命名寄存器和隐藏 FMA 延迟。
“i7”brand-name是在 Nehalem(Core2 的后继者) 中引入的,有些英特尔手册甚至说 Core i7 似乎是指 Nehalem,但是他们保留了“i7”品牌 for Sandybridge and later microarchitectures. SnB is when the P6-family evolved into a new species, the SnB-family。在许多方面,Nehalem 与 Pentium III 的共同点多于与 Sandybridge 的共同点(例如寄存器读取停顿又名 ROB-read 停顿不会发生在 SnB 上,因为它改为使用物理寄存器文件。还有一个 uop 缓存和一个不同的内部 uop 格式)。 术语“i7 架构”没有用,因为将 SnB-family 与 Nehalem 而不是 Core2 归为一组毫无意义。 (尽管如此,Nehalem 确实引入了用于将多个内核连接在一起的共享包容性 L3 缓存架构。并且还集成了 GPU。因此 chip-level,命名更有意义。)
恶魔般的无能可以证明的好想法总结
即使是极度无能的人也不太可能添加明显无用的工作或无限循环,并且将 C++/Boost 类 搞得一团糟超出了作业范围。
- Multi-thread 与单个 shared
std::atomic<uint64_t>
循环计数器,因此发生了正确的迭代总数。原子 uint64_t 对于 -m32 -march=i586
尤其糟糕。对于奖励积分,将其安排为未对齐,并以不均匀的分割跨越页面边界(不是4:4)。
- 其他一些 non-atomic 变量的错误共享 -> memory-order mis-speculation 管道清除,以及额外的缓存未命中。
- 不是在 FP 变量上使用
-
,而是将高字节与 0x80 进行异或以翻转符号位,导致 store-forwarding 停顿 .
- 独立计算每次迭代的时间,甚至比
RDTSC
更重。例如CPUID
/ RDTSC
或进行系统调用的时间函数。序列化指令本质上是 pipeline-unfriendly.
- 将常数乘以 divides 乘以它们的倒数(“为了便于阅读”)。 div 速度慢且未完全流水线化。
- 使用 AVX (SIMD) 向量化 multiply/sqrt,但在调用标量 math-library
exp()
和 log()
函数之前无法使用 vzeroupper
,导致AVX<->SSE 转换停滞。
- 将 RNG 输出存储在 linked 列表中,或者存储在您乱序遍历的数组中。每次迭代的结果都一样,最后求和。
也包含在这个答案中但被排除在摘要之外:在 non-pipelined CPU 上同样缓慢的建议,或者即使是恶魔般的无能也似乎没有道理的建议。例如许多 gimp-the-compiler 产生明显不同/更糟糕的 asm 的想法。
Multi-thread很惨
也许可以使用 OpenMP 来 multi-thread 循环,迭代次数非常少,开销远大于速度增益。不过,您的 monte-carlo 代码具有足够的并行性以实际获得加速。如果我们成功地让每次迭代变慢。 (每个线程计算一个部分 payoff_sum
,在末尾添加)。 #omp parallel
在该循环上可能是优化,而不是悲观。
Multi-thread 但强制两个线程共享相同的循环计数器(使用 atomic
递增,因此迭代总数是正确的)。 这似乎恶魔般的逻辑。这个意味着使用 static
变量作为循环计数器。这证明对循环计数器使用 atomic
是合理的,并创建实际的 cache-line ping-ponging (只要线程不 运行 在具有超线程的同一物理内核上;那可能不是 as 慢)。无论如何,这比 lock inc
的 un-contended 情况慢 很多 。 lock cmpxchg8b
在 32 位系统上以原子方式递增竞争的 uint64_t
将不得不在循环中重试,而不是让硬件仲裁原子 inc
.
同时创建虚假共享,其中多个线程将其私有数据(例如 RNG 状态)保存在同一缓存行的不同字节中。 (Intel tutorial about it, including perf counters to look at). There's a microarchitecture-specific aspect to this: Intel CPUs speculate on memory mis-ordering not happening, and there's a memory-order machine-clear perf event to detect this, at least on P4. The penalty might not be as large on Haswell. As that link points out, a lock
ed instruction assumes this will happen, avoiding mis-speculation. A normal load speculates that other cores won't invalidate a cache line between when the load executes and when it retires in program-order (unless you use pause
)。没有 lock
ed 说明的真正共享通常是一个错误。将 non-atomic 共享循环计数器与原子情况进行比较会很有趣。要真正悲观,保留共享原子循环计数器,并在相同或不同的缓存行中为某些其他变量导致错误共享。
随机uarch-specific个想法:
如果你能引入任何不可预知的分支,那将极大地悲观代码。现代 x86 CPUs 有很长的流水线,所以一个错误的预测花费大约 15 个周期(当 运行ning 从 uop 缓存中)。
依赖链:
我认为这是作业的预期部分之一。
通过选择具有一个长依赖链而不是多个短依赖链的操作顺序来击败 CPU 利用 instruction-level 并行性的能力。编译器不允许更改 FP 计算的操作顺序,除非您使用 -ffast-math
,因为这会改变结果(如下所述)。
要真正使其有效,请增加 loop-carried 依赖链的长度。但是,没有什么是显而易见的:所写的循环具有非常短的 loop-carried 依赖链:只是一个 FP 添加。 (3 个周期)。多次迭代可以一次计算 in-flight,因为它们可以在上一次迭代结束时的 payoff_sum +=
之前开始。 (log()
和 exp
需要很多指令,但不会比 Haswell's out-of-order window for finding parallelism: ROB size=192 fused-domain uops, and scheduler size=60 unfused-domain uops 多很多。只要当前迭代的执行进展到足以为下一次迭代发出的指令腾出空间,它的任何输入准备就绪的部分(即 independent/separate dep 链)都可以在较旧的指令使执行单元空闲时开始执行(例如,因为它们的瓶颈是延迟,而不是吞吐量。)。
RNG 状态几乎肯定会比 addps
更长 loop-carried 依赖链。
使用slower/more FP操作(尤其是division):
除以 2.0 而不是乘以 0.5,依此类推。 FP 乘法在 Intel 设计中大量流水线化,并且在 Haswell 及更高版本上每 0.5c 吞吐量一个。 FP divsd
/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 所建议的那样,使用 associative/distributive 等价物重写表达式会引入更多工作(只要您不使用 -ffast-math
来允许编译器 re-optimize)。 (exp(T*(r-0.5*v*v))
可能会变成 exp(T*r - T*v*v/2.0)
。请注意,虽然实数的数学是关联的,, even without considering overflow/NaN (which is why -ffast-math
isn't on by default). See 对于一个非常毛茸茸的嵌套 pow()
建议。
如果您可以将计算缩小到非常小的数字,那么当对两个正态数的运算产生非正态数时,FP 数学运算需要 ~120 个额外的周期来捕获微代码 .有关确切的数字和详细信息,请参阅 Agner Fog 的 microarch pdf。这不太可能,因为你有很多乘法,所以比例因子将被平方并一直下溢到 0.0。我看不出有什么方法可以用无能(甚至是恶魔般的)来证明必要的缩放是合理的,只有故意的恶意。
###如果可以使用内部函数(<immintrin.h>
)
。 Diabolical: 它是新的 weakly-ordered,所以应该让 CPU 运行 它更快吧?或者查看 linked 问题,以了解有人有可能这样做的危险(对于只有某些位置很热的分散写入)。 clflush
没有恶意可能是不可能的。
在 FP 数学运算之间使用整数洗牌导致旁路延迟。
Mixing SSE and AVX instructions without proper use of vzeroupper
causes large stalls in pre-Skylake (and a different penalty in Skylake)。即使没有那个,矢量化严重也可能比标量更糟糕(更多的循环花费在向量数据 into/out 上,而不是通过使用 256b 向量一次执行 4 Monte-Carlo 迭代的 add/sub/mul/div/sqrt 操作节省的时间) . add/sub/mul 执行单元完全流水线化并且 full-width,但 256b 向量上的 div 和 sqrt 不如 128b 向量(或标量)快,因此加速并不显着double
.
exp()
和 log()
没有硬件支持,s该部分需要将矢量元素提取回标量并单独调用库函数,然后将结果重新洗牌成矢量。 libm 通常编译为仅使用 SSE2,因此将使用标量数学指令的 legacy-SSE 编码。如果您的代码使用 256b 向量并调用 exp
而没有先执行 vzeroupper
,那么您就会停止。返回后,AVX-128 指令如 vmovsd
将下一个向量元素设置为 exp
的 arg 也会停止。然后 exp()
当它 运行 是一个 SSE 指令时会再次停止。 这正是发生的事情 in this question,导致 10 倍的减速。(感谢@ZBoson)。
另见 Nathan Kurz's experiments with Intel's math lib vs. glibc for this code. Future glibc will come with vectorized implementations of exp()
and so on.
如果定位 pre-IvB,或者尤其是。 Nehalem,尝试让 gcc 在 16 位或 8 位操作后跟 32 位或 64 位操作导致 partial-register 停顿。在大多数情况下,gcc 将在 8 或 16 位操作后使用 movzx
,但
使用(内联)asm:
使用(内联)asm,您可以破坏 uop 缓存:一个 32B 的代码块不适合三个 6uop 缓存行,强制从 uop 缓存切换到解码器。一个不称职的 ALIGN
(如 NASM 的默认值)在内部循环内的分支目标上使用许多 single-byte nop
而不是几个长的 nop
可能会成功。或者将对齐填充放在标签之后,而不是之前。 :P 这仅在前端是瓶颈时才重要,如果我们成功地对其余代码进行了悲观处理,就不会是瓶颈了。
使用 self-modifying 代码触发管道清除(又名 machine-nukes)。
来自 16 位指令的 LCP stalls 立即数太大而无法放入 8 位不太可能有用。 SnB 和更高版本上的 uop 缓存意味着您只需支付一次解码惩罚。在 Nehalem(第一个 i7)上,它可能适用于不适合 28 uop 循环缓冲区的循环。 gcc 有时会生成这样的指令,即使使用 -mtune=intel
并且它可以使用 32 位指令。
A common idiom for timing is CPUID
(to serialize) then RDTSC
。使用 CPUID
/RDTSC
分别对每个迭代进行计时,以确保 RDTSC
不会使用较早的指令重新排序,这会减慢 lot. (在现实生活中,聪明的计时方式是将所有迭代计时在一起,而不是分别计时并将它们相加)。
导致大量缓存未命中和其他内存减慢
对某些变量使用 union { double d; char a[8]; }
。 Cause a store-forwarding stall 通过对一个字节进行窄存储(或 Read-Modify-Write)。 (该 wiki 文章还涵盖了 load/store 队列的许多其他微体系结构内容)。例如仅在高字节 上使用 XOR 0x80 而不是 -
运算符来翻转 double
的符号。极端无能的开发人员可能听说过 FP 比整数慢,因此尝试尽可能多地使用整数操作。 (编译器理论上仍然可以将其编译为 xorps
,并使用 -
等常量,但对于 x87,编译器必须意识到它正在否定值和 fchs
或替换下一个添加减去。)
如果您使用 -O3
进行编译而不使用 std::atomic
,请使用 volatile
,以强制编译器在所有地方实际 store/reload。全局变量(而不是局部变量)也会强制一些 stores/reloads,但是 the C++ memory model's weak ordering 不需要编译器一直 spill/reload 到内存。
用大结构的成员替换局部变量,这样你就可以控制内存布局。
在结构中使用数组进行填充(并存储随机数,以证明它们的存在)。
选择内存布局 。它只有 8 向关联,即每组有 8 种“方式”。缓存行是64B.
更妙的是,将事物准确地分开 4096B,因为加载对不同页面的存储具有错误的依赖性,但在页面中具有相同的偏移量。激进 out-of-order CPUs 使用 Memory Disambiguation to figure out when loads and stores can be reordered without changing the results, and Intel's implementation has false-positives that prevent loads from starting early. Probably they only check bits below the page offset so it can start before the TLB has translated the high bits from a virtual page to a physical page. As well as Agner's guide, see this answer, and a section near the end of @Krazy Glew's answer on the same question. (Andy Glew was an architect of Intel's PPro - P6 microarchitecture.) (Also related: and https://github.com/travisdowns/uarch-bench/wiki/Memory-Disambiguation-on-Skylake)
使用 __attribute__((packed))
让您 mis-align 变量可以跨越 cache-line 甚至页面边界。 (所以一个 double
的负载需要来自两个 cache-line 的数据)。未对齐的加载在任何 Intel i7 uarch 中都没有惩罚,除非跨越高速缓存行和页面行。 Cache-line splits still take extra cycles. Skylake dramatically reduces the penalty for page split loads, from 100 to 5 cycles. (Section 2.1.3)。 (并且可以并行进行两页浏览)。
atomic<uint64_t>
上的 page-split 应该是最坏的情况 ,尤其是。如果它在一页中为 5 个字节而在另一页中为 3 个字节,或者 4:4 以外的任何内容。对于某些 uarches 上使用 16B 向量的 cache-line 拆分,即使从中间拆分也更有效,IIRC。将所有内容放入 alignas(4096) struct __attribute((packed))
(当然是为了保存 space),包括用于存储 RNG 结果的数组。通过在计数器之前使用 uint8_t
或 uint16_t
来实现错位。
如果您能让编译器使用索引寻址模式,那将 defeat uop micro-fusion。也许通过使用 #define
s 将简单的标量变量替换为 my_data[constant]
.
如果你能介绍一个额外的水平o间接的,所以 load/store 地址不是早期已知的,这可以进一步悲观。
按non-contiguous顺序遍历数组
我认为我们可以为首先引入数组提出无能的理由:它让我们将随机数生成与随机数使用分开。每次迭代的结果也可以存储在一个数组中,稍后再求和(更加无能)。
对于“最大随机性”,我们可以让一个线程循环遍历随机数组,将新的随机数写入其中。使用随机数的线程可以生成随机索引以从中加载随机数。 (这里有一些 make-work,但在微架构上它有助于尽早知道 load-addresses,因此可以在需要加载数据之前解决任何可能的加载延迟。)有一个 reader 和 writer on不同的核心将导致 memory-ordering mis-speculation 管道清除(如前所述 false-sharing 案例)。
为了最大限度地悲观化,以 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]
这样的二维数组时得到的结果(按照@JesperJuhl 的建议,循环遍历行,而不是在内循环中一行中的列)。如果恶魔般的无能可以证明具有这样尺寸的二维数组是合理的,那么普通的 real-world 无能很容易证明使用错误的访问模式进行循环是合理的。这发生在现实生活中的真实代码中。
如果数组不是那么大,请根据需要调整循环边界以使用许多不同的页面而不是重复使用相同的几页。跨页面的硬件预取不起作用(因为 well/at 全部)。预取器可以在每一页中跟踪一个前向流和一个后向流(这就是这里发生的情况),但只有在内存带宽尚未饱和时才会对其起作用 non-prefetch.
这也会产生大量的 TLB 未命中,除非页面被合并成一个大页面 (Linux does this opportunistically for anonymous (not file-backed) allocations like malloc
/new
that use mmap(MAP_ANONYMOUS)
)。
您可以使用 linked 列表,而不是数组来存储结果列表。每次迭代都需要 pointer-chasing 加载(下一次加载的 load-address 的 RAW 真实依赖风险)。使用错误的分配器,您可能会设法将列表节点分散在内存中,从而破坏缓存。使用错误的玩具分配器,它可以将每个节点放在其自己页面的开头。 (例如,直接使用 mmap(MAP_ANONYMOUS)
进行分配,无需拆分页面或跟踪对象大小以正确支持 free
)。
这些并不是真正的 microarchitecture-specific,并且与管道关系不大(其中大部分也将是 non-pipelined CPU 上的减速)。
有点off-topic:让编译器生成更糟糕的代码/做更多的工作:
使用 C++11 std::atomic<int>
和 std::atomic<double>
以获得最简单的代码。即使没有来自另一个线程的争用,MFENCE 和 lock
ed 指令也非常慢。
-m32
会使代码变慢,因为 x87 代码会比 SSE2 代码差。 stack-based 32 位调用约定需要更多指令,甚至将堆栈上的 FP args 传递给 exp()
等函数。 atomic<uint64_t>::operator++
on -m32
requires a lock cmpxchg8B
loop (i586)。 (所以用它来循环计数器![邪恶的笑])。
-march=i386
也会悲观(感谢@Jesper)。 FP 与 fcom
比较比 686 fcomi
慢。 Pre-586 不提供原子 64 位存储(更不用说 cmpxchg),因此所有 64 位 atomic
操作都编译为 libgcc 函数调用(这可能是为 i686 编译的,而不是实际使用锁)。在最后一段中的 Godbolt Compiler Explorer link 上尝试。
使用 long double
/ sqrtl
/ expl
在 sizeof(long double
) 为 10 或 16 的 ABI 中获得额外的精度和额外的缓慢(使用填充对齐) . (IIRC,64 位 Windows 使用 8 字节 long double
相当于 double
。(无论如何,10 字节(80 位)FP 操作数的 load/store 是 4 / 7 uops,与 float
或 double
每个 fld m64/m32
/fst
只占用 1 个 uop。用 long double
强制 x87 会击败 auto-vectorization,即使对于 gcc -m64 -march=haswell -O3
。
如果不使用 atomic<uint64_t>
循环计数器,请对所有内容使用 long double
,包括循环计数器。
atomic<double>
编译,但 read-modify-write 操作如 +=
不受支持(即使在 64 位上)。 atomic<long double>
必须为原子 loads/stores 调用一个库函数。这可能真的很低效,,我能想到的唯一没有锁定的方法(cmpxchg16b
)需要64位模式。
在 -O0
,通过将部分分配给临时变量来分解大表达式将导致更多 store/reload。如果没有 volatile
之类的东西,这与实际代码的实际构建将使用的优化设置无关紧要。
C 别名规则允许 char
为任何东西起别名,因此通过 char*
存储会强制编译器 store/reload 一切 before/after byte-store,即使在-O3
。 (这是 auto-vectorizing 的问题。)
尝试 uint16_t
循环计数器,强制 t运行cation 为 16 位,可能是通过使用 16 位 operand-size(潜在停顿)and/or 额外 movzx
说明(安全)。 , so unless you use -fwrapv
or at least -fno-strict-overflow
, signed loop counters don't have to be re-sign-extended every iteration,即使用作 64 位指针的偏移量。
强制从整数转换为 float
,然后再返回。 And/or double
<=>float
次转化。指令的延迟 > 1,并且标量 int->float (cvtsi2ss
) 设计不当,无法将 xmm 寄存器的其余部分置零。 (出于这个原因,gcc 插入了一个额外的 pxor
来打破依赖关系。)
经常 将您的 CPU 亲和力设置为不同的 CPU(@Egwor 建议)。邪恶的推理:您不希望一个核心因 运行 长时间使用您的线程而过热,是吗?也许交换到另一个核心将使该核心加速到更高的时钟速度。 (实际上:它们在热学上非常接近,除非在 multi-socket 系统中,否则这种情况极不可能发生)。现在只是把调音弄错了,而且调得太频繁了。除了在 OS saving/restoring 线程状态中花费的时间外,新内核还有冷 L2/L1 缓存、uop 缓存和分支预测器。
频繁引入不必要的系统调用,无论它们是什么,都会减慢您的速度。虽然一些重要但简单的像 gettimeofday
可以在 user-space 中实现,但不会转换到内核模式。 (Linux 上的 glibc 在内核的帮助下完成此操作:内核在 VDSO 中导出代码+数据)。
有关系统调用开销的更多信息(包括 cache/TLB 返回 user-space 后的未命中,而不仅仅是上下文切换本身),FlexSC paper 有一些很棒的 perf-counter当前情况的分析,以及对来自大量 multi-threaded 服务器进程的批处理系统调用的建议。
你可以做一些事情来让事情表现得尽可能糟糕:
编译 i386 架构的代码。这将阻止使用 SSE 和更新的指令并强制使用 x87 FPU。
到处使用std::atomic
变量。这将使它们变得非常昂贵,因为编译器被迫在各处插入内存屏障。这是一个无能的人可能对 "ensure thread safety".
做的事
确保以预取器预测的最坏可能方式访问内存(主要列与主要行)。
为了让你的变量更加昂贵,你可以确保它们都有 'dynamic storage duration'(堆分配),方法是用 new
分配它们而不是让它们有 'automatic storage duration'(堆栈分配)。
确保您分配的所有内存都非常奇怪地对齐,并且一定要避免分配大页面,因为这样做 TLB 效率太高了。
无论您做什么,都不要在启用编译器优化器的情况下构建您的代码。并确保启用最具表现力的调试符号(不会使代码 运行 变慢,但会浪费一些额外的磁盘 space) .
注意:这个答案基本上只是总结了我的评论,@Peter Cordes 已经纳入了他非常好的答案。如果你只有一个空闲的话,建议他得到你的支持:)
您可以使用long double
进行计算。在 x86 上,它应该是 80 位格式。只有遗留的 x87 FPU 支持这个。
x87 FPU 的一些缺点:
- 缺少 SIMD,可能需要更多指令。
- 基于堆栈,超标量和流水线架构存在问题。
- 独立且很小的一组寄存器,可能需要从其他寄存器进行更多转换和更多内存操作。
- Core i7 上有 3 个 SSE 端口,只有 2 个 x87 端口,处理器可以执行较少的并行指令。
迟到的答案,但我觉得我们滥用链表和 TLB 的程度还不够。
使用 mmap 分配您的节点,以便您主要使用地址的 MSB。这应该会导致长 TLB 查找链,一个页面是 12 位,留下 52 位用于转换,或者每次必须遍历大约 5 个级别。幸运的是,它们每次都必须进入内存进行 5 级查找加上 1 次内存访问才能到达您的节点,顶层很可能位于缓存中的某个地方,因此我们可以希望获得 5* 内存访问。放置节点,使其跨越最差边界,以便读取下一个指针将导致另外 3-4 次翻译查找。由于大量的翻译查找,这也可能完全破坏缓存。此外,虚拟表的大小可能会导致大部分用户数据被分页到磁盘以延长时间。
从单链表读取时,确保每次都从链表的开头读取,以最大延迟读取单个数字。
为了完成这项作业,我绞尽脑汁了一个星期,希望有人能指引我走向正确的道路。让我先从导师的指示说起:
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 程序的选择。 cache-effectiveness评论大多只适用于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;
}
我所做的更改似乎使代码 运行ning 时间增加了一秒钟,但我不完全确定我可以更改什么来在不添加代码的情况下停止管道。指出正确的方向会很棒,我很感激任何回复。
更新:the professor who gave this assignment posted some details
重点是:
- 这是社区学院第二学期的建筑class(使用 Hennessy 和 Patterson 教科书)。
- 实验室计算机有 Haswell CPU
- 同学们已经了解了
CPUID
指令和如何确定缓存大小,以及内部函数和CLFLUSH
指令。 - 允许任何编译器选项,内联 asm 也是如此。
- 编写您自己的平方根算法被宣布为过时
Cowmoogun 在元线程上的评论表明 it wasn't clear compiler optimizations could be part of this, and assumed -O0
,运行 时间增加 17% 是合理的。
所以听起来作业的目标是让学生重新排序现有的工作以减少指令级并行性或类似的东西,但人们更深入地研究并学到更多并不是一件坏事.
请记住,这是一个计算机体系结构问题,而不是关于如何使 C++ 通常变慢的问题。
重要背景阅读:Agner Fog's microarch pdf, and probably also Ulrich Drepper's What Every Programmer Should Know About Memory. See also the other links in the x86 tag wiki, especially Intel's optimization manuals, and David Kanter's analysis of the Haswell microarchitecture, with diagrams.
非常酷的作业;比我在
作业措辞和代码有问题:
此代码的 uarch-specific 选项有限。它不使用任何数组,大部分成本是对 exp
/log
库函数的调用。没有明显的方法来获得或多或少的 instruction-level 并行性,并且 loop-carried 依赖链非常短。
很难仅通过 re-arranging 表达式来降低依赖性,以减少 ILP 的危害。
英特尔 Sandybridge-family CPU 是激进的 out-of-order 设计,它花费大量的晶体管和功率来寻找并行性并避免会给 a classic RISC in-order pipeline 带来麻烦的危险(依赖性)。通常唯一会减慢速度的传统危害是 RAW“真实”依赖性,它会导致吞吐量受到延迟的限制。
WAR and WAW hazards for registers are pretty much not an issue, thanks to register renaming. (except for popcnt
/lzcnt
/tzcnt
, which have a false dependency their destination on Intel CPUs,虽然应该是write-only).
对于内存排序,现代 CPUs 使用 store buffer to delay commit into cache until retirement, also avoiding WAR and WAW hazards. See also this answer 关于什么是存储缓冲区,并且对于 OoO exec 将执行与其他内核可以看到的东西分离是必不可少的。
“i7”brand-name是在 Nehalem(Core2 的后继者) 中引入的,有些英特尔手册甚至说 Core i7 似乎是指 Nehalem,但是他们保留了“i7”品牌 for Sandybridge and later microarchitectures. SnB is when the P6-family evolved into a new species, the SnB-family。在许多方面,Nehalem 与 Pentium III 的共同点多于与 Sandybridge 的共同点(例如寄存器读取停顿又名 ROB-read 停顿不会发生在 SnB 上,因为它改为使用物理寄存器文件。还有一个 uop 缓存和一个不同的内部 uop 格式)。 术语“i7 架构”没有用,因为将 SnB-family 与 Nehalem 而不是 Core2 归为一组毫无意义。 (尽管如此,Nehalem 确实引入了用于将多个内核连接在一起的共享包容性 L3 缓存架构。并且还集成了 GPU。因此 chip-level,命名更有意义。)
恶魔般的无能可以证明的好想法总结
即使是极度无能的人也不太可能添加明显无用的工作或无限循环,并且将 C++/Boost 类 搞得一团糟超出了作业范围。
- Multi-thread 与单个 shared
std::atomic<uint64_t>
循环计数器,因此发生了正确的迭代总数。原子 uint64_t 对于-m32 -march=i586
尤其糟糕。对于奖励积分,将其安排为未对齐,并以不均匀的分割跨越页面边界(不是4:4)。 - 其他一些 non-atomic 变量的错误共享 -> memory-order mis-speculation 管道清除,以及额外的缓存未命中。
- 不是在 FP 变量上使用
-
,而是将高字节与 0x80 进行异或以翻转符号位,导致 store-forwarding 停顿 . - 独立计算每次迭代的时间,甚至比
RDTSC
更重。例如CPUID
/RDTSC
或进行系统调用的时间函数。序列化指令本质上是 pipeline-unfriendly. - 将常数乘以 divides 乘以它们的倒数(“为了便于阅读”)。 div 速度慢且未完全流水线化。
- 使用 AVX (SIMD) 向量化 multiply/sqrt,但在调用标量 math-library
exp()
和log()
函数之前无法使用vzeroupper
,导致AVX<->SSE 转换停滞。 - 将 RNG 输出存储在 linked 列表中,或者存储在您乱序遍历的数组中。每次迭代的结果都一样,最后求和。
也包含在这个答案中但被排除在摘要之外:在 non-pipelined CPU 上同样缓慢的建议,或者即使是恶魔般的无能也似乎没有道理的建议。例如许多 gimp-the-compiler 产生明显不同/更糟糕的 asm 的想法。
Multi-thread很惨
也许可以使用 OpenMP 来 multi-thread 循环,迭代次数非常少,开销远大于速度增益。不过,您的 monte-carlo 代码具有足够的并行性以实际获得加速。如果我们成功地让每次迭代变慢。 (每个线程计算一个部分 payoff_sum
,在末尾添加)。 #omp parallel
在该循环上可能是优化,而不是悲观。
Multi-thread 但强制两个线程共享相同的循环计数器(使用 atomic
递增,因此迭代总数是正确的)。 这似乎恶魔般的逻辑。这个意味着使用 static
变量作为循环计数器。这证明对循环计数器使用 atomic
是合理的,并创建实际的 cache-line ping-ponging (只要线程不 运行 在具有超线程的同一物理内核上;那可能不是 as 慢)。无论如何,这比 lock inc
的 un-contended 情况慢 很多 。 lock cmpxchg8b
在 32 位系统上以原子方式递增竞争的 uint64_t
将不得不在循环中重试,而不是让硬件仲裁原子 inc
.
同时创建虚假共享,其中多个线程将其私有数据(例如 RNG 状态)保存在同一缓存行的不同字节中。 (Intel tutorial about it, including perf counters to look at). There's a microarchitecture-specific aspect to this: Intel CPUs speculate on memory mis-ordering not happening, and there's a memory-order machine-clear perf event to detect this, at least on P4. The penalty might not be as large on Haswell. As that link points out, a lock
ed instruction assumes this will happen, avoiding mis-speculation. A normal load speculates that other cores won't invalidate a cache line between when the load executes and when it retires in program-order (unless you use pause
)。没有 lock
ed 说明的真正共享通常是一个错误。将 non-atomic 共享循环计数器与原子情况进行比较会很有趣。要真正悲观,保留共享原子循环计数器,并在相同或不同的缓存行中为某些其他变量导致错误共享。
随机uarch-specific个想法:
如果你能引入任何不可预知的分支,那将极大地悲观代码。现代 x86 CPUs 有很长的流水线,所以一个错误的预测花费大约 15 个周期(当 运行ning 从 uop 缓存中)。
依赖链:
我认为这是作业的预期部分之一。
通过选择具有一个长依赖链而不是多个短依赖链的操作顺序来击败 CPU 利用 instruction-level 并行性的能力。编译器不允许更改 FP 计算的操作顺序,除非您使用 -ffast-math
,因为这会改变结果(如下所述)。
要真正使其有效,请增加 loop-carried 依赖链的长度。但是,没有什么是显而易见的:所写的循环具有非常短的 loop-carried 依赖链:只是一个 FP 添加。 (3 个周期)。多次迭代可以一次计算 in-flight,因为它们可以在上一次迭代结束时的 payoff_sum +=
之前开始。 (log()
和 exp
需要很多指令,但不会比 Haswell's out-of-order window for finding parallelism: ROB size=192 fused-domain uops, and scheduler size=60 unfused-domain uops 多很多。只要当前迭代的执行进展到足以为下一次迭代发出的指令腾出空间,它的任何输入准备就绪的部分(即 independent/separate dep 链)都可以在较旧的指令使执行单元空闲时开始执行(例如,因为它们的瓶颈是延迟,而不是吞吐量。)。
RNG 状态几乎肯定会比 addps
更长 loop-carried 依赖链。
使用slower/more FP操作(尤其是division):
除以 2.0 而不是乘以 0.5,依此类推。 FP 乘法在 Intel 设计中大量流水线化,并且在 Haswell 及更高版本上每 0.5c 吞吐量一个。 FP divsd
/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 所建议的那样,使用 associative/distributive 等价物重写表达式会引入更多工作(只要您不使用 -ffast-math
来允许编译器 re-optimize)。 (exp(T*(r-0.5*v*v))
可能会变成 exp(T*r - T*v*v/2.0)
。请注意,虽然实数的数学是关联的,-ffast-math
isn't on by default). See pow()
建议。
如果您可以将计算缩小到非常小的数字,那么当对两个正态数的运算产生非正态数时,FP 数学运算需要 ~120 个额外的周期来捕获微代码 .有关确切的数字和详细信息,请参阅 Agner Fog 的 microarch pdf。这不太可能,因为你有很多乘法,所以比例因子将被平方并一直下溢到 0.0。我看不出有什么方法可以用无能(甚至是恶魔般的)来证明必要的缩放是合理的,只有故意的恶意。
###如果可以使用内部函数(<immintrin.h>
)
clflush
没有恶意可能是不可能的。
在 FP 数学运算之间使用整数洗牌导致旁路延迟。
Mixing SSE and AVX instructions without proper use of vzeroupper
causes large stalls in pre-Skylake (and a different penalty in Skylake)。即使没有那个,矢量化严重也可能比标量更糟糕(更多的循环花费在向量数据 into/out 上,而不是通过使用 256b 向量一次执行 4 Monte-Carlo 迭代的 add/sub/mul/div/sqrt 操作节省的时间) . add/sub/mul 执行单元完全流水线化并且 full-width,但 256b 向量上的 div 和 sqrt 不如 128b 向量(或标量)快,因此加速并不显着double
.
exp()
和 log()
没有硬件支持,s该部分需要将矢量元素提取回标量并单独调用库函数,然后将结果重新洗牌成矢量。 libm 通常编译为仅使用 SSE2,因此将使用标量数学指令的 legacy-SSE 编码。如果您的代码使用 256b 向量并调用 exp
而没有先执行 vzeroupper
,那么您就会停止。返回后,AVX-128 指令如 vmovsd
将下一个向量元素设置为 exp
的 arg 也会停止。然后 exp()
当它 运行 是一个 SSE 指令时会再次停止。 这正是发生的事情 in this question,导致 10 倍的减速。(感谢@ZBoson)。
另见 Nathan Kurz's experiments with Intel's math lib vs. glibc for this code. Future glibc will come with vectorized implementations of exp()
and so on.
如果定位 pre-IvB,或者尤其是。 Nehalem,尝试让 gcc 在 16 位或 8 位操作后跟 32 位或 64 位操作导致 partial-register 停顿。在大多数情况下,gcc 将在 8 或 16 位操作后使用 movzx
,但
使用(内联)asm:
使用(内联)asm,您可以破坏 uop 缓存:一个 32B 的代码块不适合三个 6uop 缓存行,强制从 uop 缓存切换到解码器。一个不称职的 ALIGN
(如 NASM 的默认值)在内部循环内的分支目标上使用许多 single-byte nop
而不是几个长的 nop
可能会成功。或者将对齐填充放在标签之后,而不是之前。 :P 这仅在前端是瓶颈时才重要,如果我们成功地对其余代码进行了悲观处理,就不会是瓶颈了。
使用 self-modifying 代码触发管道清除(又名 machine-nukes)。
来自 16 位指令的LCP stalls 立即数太大而无法放入 8 位不太可能有用。 SnB 和更高版本上的 uop 缓存意味着您只需支付一次解码惩罚。在 Nehalem(第一个 i7)上,它可能适用于不适合 28 uop 循环缓冲区的循环。 gcc 有时会生成这样的指令,即使使用 -mtune=intel
并且它可以使用 32 位指令。
A common idiom for timing is CPUID
(to serialize) then RDTSC
。使用 CPUID
/RDTSC
分别对每个迭代进行计时,以确保 RDTSC
不会使用较早的指令重新排序,这会减慢 lot. (在现实生活中,聪明的计时方式是将所有迭代计时在一起,而不是分别计时并将它们相加)。
导致大量缓存未命中和其他内存减慢
对某些变量使用 union { double d; char a[8]; }
。 Cause a store-forwarding stall 通过对一个字节进行窄存储(或 Read-Modify-Write)。 (该 wiki 文章还涵盖了 load/store 队列的许多其他微体系结构内容)。例如仅在高字节 上使用 XOR 0x80 而不是 -
运算符来翻转 double
的符号。极端无能的开发人员可能听说过 FP 比整数慢,因此尝试尽可能多地使用整数操作。 (编译器理论上仍然可以将其编译为 xorps
,并使用 -
等常量,但对于 x87,编译器必须意识到它正在否定值和 fchs
或替换下一个添加减去。)
如果您使用 -O3
进行编译而不使用 std::atomic
,请使用 volatile
,以强制编译器在所有地方实际 store/reload。全局变量(而不是局部变量)也会强制一些 stores/reloads,但是 the C++ memory model's weak ordering 不需要编译器一直 spill/reload 到内存。
用大结构的成员替换局部变量,这样你就可以控制内存布局。
在结构中使用数组进行填充(并存储随机数,以证明它们的存在)。
选择内存布局
更妙的是,将事物准确地分开 4096B,因为加载对不同页面的存储具有错误的依赖性,但在页面中具有相同的偏移量。激进 out-of-order CPUs 使用 Memory Disambiguation to figure out when loads and stores can be reordered without changing the results, and Intel's implementation has false-positives that prevent loads from starting early. Probably they only check bits below the page offset so it can start before the TLB has translated the high bits from a virtual page to a physical page. As well as Agner's guide, see this answer, and a section near the end of @Krazy Glew's answer on the same question. (Andy Glew was an architect of Intel's PPro - P6 microarchitecture.) (Also related: and https://github.com/travisdowns/uarch-bench/wiki/Memory-Disambiguation-on-Skylake)
使用 __attribute__((packed))
让您 mis-align 变量可以跨越 cache-line 甚至页面边界。 (所以一个 double
的负载需要来自两个 cache-line 的数据)。未对齐的加载在任何 Intel i7 uarch 中都没有惩罚,除非跨越高速缓存行和页面行。 Cache-line splits still take extra cycles. Skylake dramatically reduces the penalty for page split loads, from 100 to 5 cycles. (Section 2.1.3)。 (并且可以并行进行两页浏览)。
atomic<uint64_t>
上的 page-split 应该是最坏的情况 ,尤其是。如果它在一页中为 5 个字节而在另一页中为 3 个字节,或者 4:4 以外的任何内容。对于某些 uarches 上使用 16B 向量的 cache-line 拆分,即使从中间拆分也更有效,IIRC。将所有内容放入 alignas(4096) struct __attribute((packed))
(当然是为了保存 space),包括用于存储 RNG 结果的数组。通过在计数器之前使用 uint8_t
或 uint16_t
来实现错位。
如果您能让编译器使用索引寻址模式,那将 defeat uop micro-fusion。也许通过使用 #define
s 将简单的标量变量替换为 my_data[constant]
.
如果你能介绍一个额外的水平o间接的,所以 load/store 地址不是早期已知的,这可以进一步悲观。
按non-contiguous顺序遍历数组
我认为我们可以为首先引入数组提出无能的理由:它让我们将随机数生成与随机数使用分开。每次迭代的结果也可以存储在一个数组中,稍后再求和(更加无能)。
对于“最大随机性”,我们可以让一个线程循环遍历随机数组,将新的随机数写入其中。使用随机数的线程可以生成随机索引以从中加载随机数。 (这里有一些 make-work,但在微架构上它有助于尽早知道 load-addresses,因此可以在需要加载数据之前解决任何可能的加载延迟。)有一个 reader 和 writer on不同的核心将导致 memory-ordering mis-speculation 管道清除(如前所述 false-sharing 案例)。
为了最大限度地悲观化,以 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]
这样的二维数组时得到的结果(按照@JesperJuhl 的建议,循环遍历行,而不是在内循环中一行中的列)。如果恶魔般的无能可以证明具有这样尺寸的二维数组是合理的,那么普通的 real-world 无能很容易证明使用错误的访问模式进行循环是合理的。这发生在现实生活中的真实代码中。
如果数组不是那么大,请根据需要调整循环边界以使用许多不同的页面而不是重复使用相同的几页。跨页面的硬件预取不起作用(因为 well/at 全部)。预取器可以在每一页中跟踪一个前向流和一个后向流(这就是这里发生的情况),但只有在内存带宽尚未饱和时才会对其起作用 non-prefetch.
这也会产生大量的 TLB 未命中,除非页面被合并成一个大页面 (Linux does this opportunistically for anonymous (not file-backed) allocations like malloc
/new
that use mmap(MAP_ANONYMOUS)
)。
您可以使用 linked 列表,而不是数组来存储结果列表。每次迭代都需要 pointer-chasing 加载(下一次加载的 load-address 的 RAW 真实依赖风险)。使用错误的分配器,您可能会设法将列表节点分散在内存中,从而破坏缓存。使用错误的玩具分配器,它可以将每个节点放在其自己页面的开头。 (例如,直接使用 mmap(MAP_ANONYMOUS)
进行分配,无需拆分页面或跟踪对象大小以正确支持 free
)。
这些并不是真正的 microarchitecture-specific,并且与管道关系不大(其中大部分也将是 non-pipelined CPU 上的减速)。
有点off-topic:让编译器生成更糟糕的代码/做更多的工作:
使用 C++11 std::atomic<int>
和 std::atomic<double>
以获得最简单的代码。即使没有来自另一个线程的争用,MFENCE 和 lock
ed 指令也非常慢。
-m32
会使代码变慢,因为 x87 代码会比 SSE2 代码差。 stack-based 32 位调用约定需要更多指令,甚至将堆栈上的 FP args 传递给 exp()
等函数。 atomic<uint64_t>::operator++
on -m32
requires a lock cmpxchg8B
loop (i586)。 (所以用它来循环计数器![邪恶的笑])。
-march=i386
也会悲观(感谢@Jesper)。 FP 与 fcom
比较比 686 fcomi
慢。 Pre-586 不提供原子 64 位存储(更不用说 cmpxchg),因此所有 64 位 atomic
操作都编译为 libgcc 函数调用(这可能是为 i686 编译的,而不是实际使用锁)。在最后一段中的 Godbolt Compiler Explorer link 上尝试。
使用 long double
/ sqrtl
/ expl
在 sizeof(long double
) 为 10 或 16 的 ABI 中获得额外的精度和额外的缓慢(使用填充对齐) . (IIRC,64 位 Windows 使用 8 字节 long double
相当于 double
。(无论如何,10 字节(80 位)FP 操作数的 load/store 是 4 / 7 uops,与 float
或 double
每个 fld m64/m32
/fst
只占用 1 个 uop。用 long double
强制 x87 会击败 auto-vectorization,即使对于 gcc -m64 -march=haswell -O3
。
如果不使用 atomic<uint64_t>
循环计数器,请对所有内容使用 long double
,包括循环计数器。
atomic<double>
编译,但 read-modify-write 操作如 +=
不受支持(即使在 64 位上)。 atomic<long double>
必须为原子 loads/stores 调用一个库函数。这可能真的很低效,cmpxchg16b
)需要64位模式。
在 -O0
,通过将部分分配给临时变量来分解大表达式将导致更多 store/reload。如果没有 volatile
之类的东西,这与实际代码的实际构建将使用的优化设置无关紧要。
C 别名规则允许 char
为任何东西起别名,因此通过 char*
存储会强制编译器 store/reload 一切 before/after byte-store,即使在-O3
。 (这是 auto-vectorizing
尝试 uint16_t
循环计数器,强制 t运行cation 为 16 位,可能是通过使用 16 位 operand-size(潜在停顿)and/or 额外 movzx
说明(安全)。 -fwrapv
or at least -fno-strict-overflow
, signed loop counters don't have to be re-sign-extended every iteration,即使用作 64 位指针的偏移量。
强制从整数转换为 float
,然后再返回。 And/or double
<=>float
次转化。指令的延迟 > 1,并且标量 int->float (cvtsi2ss
) 设计不当,无法将 xmm 寄存器的其余部分置零。 (出于这个原因,gcc 插入了一个额外的 pxor
来打破依赖关系。)
经常 将您的 CPU 亲和力设置为不同的 CPU(@Egwor 建议)。邪恶的推理:您不希望一个核心因 运行 长时间使用您的线程而过热,是吗?也许交换到另一个核心将使该核心加速到更高的时钟速度。 (实际上:它们在热学上非常接近,除非在 multi-socket 系统中,否则这种情况极不可能发生)。现在只是把调音弄错了,而且调得太频繁了。除了在 OS saving/restoring 线程状态中花费的时间外,新内核还有冷 L2/L1 缓存、uop 缓存和分支预测器。
频繁引入不必要的系统调用,无论它们是什么,都会减慢您的速度。虽然一些重要但简单的像 gettimeofday
可以在 user-space 中实现,但不会转换到内核模式。 (Linux 上的 glibc 在内核的帮助下完成此操作:内核在 VDSO 中导出代码+数据)。
有关系统调用开销的更多信息(包括 cache/TLB 返回 user-space 后的未命中,而不仅仅是上下文切换本身),FlexSC paper 有一些很棒的 perf-counter当前情况的分析,以及对来自大量 multi-threaded 服务器进程的批处理系统调用的建议。
你可以做一些事情来让事情表现得尽可能糟糕:
编译 i386 架构的代码。这将阻止使用 SSE 和更新的指令并强制使用 x87 FPU。
到处使用
std::atomic
变量。这将使它们变得非常昂贵,因为编译器被迫在各处插入内存屏障。这是一个无能的人可能对 "ensure thread safety". 做的事
确保以预取器预测的最坏可能方式访问内存(主要列与主要行)。
为了让你的变量更加昂贵,你可以确保它们都有 'dynamic storage duration'(堆分配),方法是用
new
分配它们而不是让它们有 'automatic storage duration'(堆栈分配)。确保您分配的所有内存都非常奇怪地对齐,并且一定要避免分配大页面,因为这样做 TLB 效率太高了。
无论您做什么,都不要在启用编译器优化器的情况下构建您的代码。并确保启用最具表现力的调试符号(不会使代码 运行 变慢,但会浪费一些额外的磁盘 space) .
注意:这个答案基本上只是总结了我的评论,@Peter Cordes 已经纳入了他非常好的答案。如果你只有一个空闲的话,建议他得到你的支持:)
您可以使用long double
进行计算。在 x86 上,它应该是 80 位格式。只有遗留的 x87 FPU 支持这个。
x87 FPU 的一些缺点:
- 缺少 SIMD,可能需要更多指令。
- 基于堆栈,超标量和流水线架构存在问题。
- 独立且很小的一组寄存器,可能需要从其他寄存器进行更多转换和更多内存操作。
- Core i7 上有 3 个 SSE 端口,只有 2 个 x87 端口,处理器可以执行较少的并行指令。
迟到的答案,但我觉得我们滥用链表和 TLB 的程度还不够。
使用 mmap 分配您的节点,以便您主要使用地址的 MSB。这应该会导致长 TLB 查找链,一个页面是 12 位,留下 52 位用于转换,或者每次必须遍历大约 5 个级别。幸运的是,它们每次都必须进入内存进行 5 级查找加上 1 次内存访问才能到达您的节点,顶层很可能位于缓存中的某个地方,因此我们可以希望获得 5* 内存访问。放置节点,使其跨越最差边界,以便读取下一个指针将导致另外 3-4 次翻译查找。由于大量的翻译查找,这也可能完全破坏缓存。此外,虚拟表的大小可能会导致大部分用户数据被分页到磁盘以延长时间。
从单链表读取时,确保每次都从链表的开头读取,以最大延迟读取单个数字。