作为 Python 的基线,我用 C 编写了这个(然后决定使用 C++ 来实现更方便的数组 I/O)。它使用 clang++ 为 x86-64 高效编译。在 Skylake x86 上运行问题中的代码,这比 CPython3.6.2 快 82 倍,因此即使您更快的 Python 版本仍然无法跟上接近最佳的机器代码。 (是的,我查看了编译器的 asm 输出以检查它是否做得很好)。
让一个好的 JIT 或提前编译器看到循环逻辑是性能的关键。问题逻辑本质上是串行的,所以没有空间让 Python 运行已经编译C 在整个数组上做一些事情(比如 NumPy),因为除非你使用 Cython 或其他东西,否则不会为这个特定问题编译 C。让问题的每一步都返回到 CPython 解释器是性能的死亡,当它的缓慢没有被内存瓶颈或任何东西隐藏时。
更新:将偏移数组转换为指针数组可将其速度提高 1.5 倍(简单寻址模式 + 从关键路径循环承载的依赖链中删除 add , 对于简单的寻址模式 (when the pointer comes from another load),将其降低到 4 cycle L1D load-use latency,而不是索引寻址模式的 6c = 5c + 1c + add 延迟)。
但我认为我们可以对 Python 大方一点,不要指望它跟上算法转换以适应现代 CPU :P(特别是因为即使在 64 位模式下我也使用 32 位指针来确保 4585 元素数组仍然只有 18kiB,因此它适合 32kiB L1D 缓存。就像 Linux x32 ABI 或 AArch64 ILP32 ABI 一样。)
此外,更新的替代表达式让 gcc 像 clang 一样无分支地编译它。 (注释掉并且原始perf stat 输出留在这个答案中,因为有趣的是看到无分支与有错误预测的分支的效果。)
unsigned jumps(int offset[], unsigned size) {
unsigned location = 0;
unsigned counter = 0;
do {
//location += offset[location]++; // simple version
// >=3 conditional version below
int off = offset[location];
offset[location] += (off>=3) ? -1 : 1; // branchy with gcc
// offset[location] = (off>=3) ? off-1 : off+1; // branchless with gcc and clang.
location += off;
counter++;
} while (location < size);
return counter;
}
#include <iostream>
#include <iterator>
#include <vector>
int main()
{
std::ios::sync_with_stdio(false); // makes cin faster
std::istream_iterator<int> begin(std::cin), dummy;
std::vector<int> values(begin, dummy); // construct a dynamic array from reading stdin
unsigned count = jumps(values.data(), values.size());
std::cout << count << '\n';
}
使用clang4.0.1 -O3 -march=skylake,内循环是无分支的;它对>=3 条件使用条件移动。我使用了? :,因为这就是我希望编译器能够做到的。 Source + asm on the Godbolt compiler explorer
.LBB1_4: # =>This Inner Loop Header: Depth=1
mov ebx, edi ; silly compiler: extra work inside the loop to save code outside
mov esi, dword ptr [rax + 4*rbx] ; off = offset[location]
cmp esi, 2
mov ecx, 1
cmovg ecx, r8d ; ecx = (off>=3) ? -1 : 1; // r8d = -1 (set outside the loop)
add ecx, esi ; off += -1 or 1
mov dword ptr [rax + 4*rbx], ecx ; store back the updated off
add edi, esi ; location += off (original value)
add edx, 1 ; counter++
cmp edi, r9d
jb .LBB1_4 ; unsigned compare against array size
这是我的 i7-6700k Skylake CPU 上 perf stat ./a.out < input.txt(clang 版本)的输出:
21841249 # correct total, matches Python
Performance counter stats for './a.out':
36.843436 task-clock (msec) # 0.997 CPUs utilized
0 context-switches # 0.000 K/sec
0 cpu-migrations # 0.000 K/sec
119 page-faults # 0.003 M/sec
143,680,934 cycles # 3.900 GHz
245,059,492 instructions # 1.71 insn per cycle
22,654,670 branches # 614.890 M/sec
20,171 branch-misses # 0.09% of all branches
0.036953258 seconds time elapsed
由于循环中的数据依赖性,平均每时钟指令数远低于 4。下一次迭代的加载地址取决于本次迭代的load+add,乱序执行无法隐藏。不过,它可以重叠所有更新当前位置值的工作。
从int 更改为short 没有性能下降(正如预期的那样;movsx has the same latency as mov on Skylake),但内存消耗减半,因此如果需要,更大的数组可以容纳在 L1D 缓存中。
我尝试将数组编译到程序中(如int offsets[] = { file contents with commas added };,因此不必读取和解析它。它还使大小成为编译时常量。这将运行时间减少到 ~36.2 +/- 0.1 毫秒,低于约 36.8 毫秒,因此从文件读取的版本仍然将大部分时间花在实际问题上,而不是解析输入。(与 Python 不同,C++ 的启动开销可以忽略不计,我的 Skylake CPU 加速到由于 Skylake 中的硬件 P 状态管理,最大时钟速度远低于一毫秒。)
如前所述,使用像[rdi] 这样的简单寻址模式而不是[rdi + rdx*4] 的指针追踪具有低1c 的延迟,并且避免了add(index += offset 变为current = target)。英特尔因为 IvyBridge 在寄存器之间具有零延迟整数mov,因此不会延长关键路径。这是the source (with comments) + asm for this hacky optimization。典型运行(将文本解析为 std::vector):23.26 +- 0.05 ms,90.725 M 周期(3.900 GHz),288.724 M instructions(3.18 IPC)。有趣的是,它实际上是更多的总指令,但由于循环携带的依赖链的延迟较低,因此运行速度更快。
gcc 使用一个分支,它的速度大约慢了 2 倍。 (根据 perf stat 对整个程序的 14% 的分支进行了错误预测。 它只是作为更新值的一部分进行分支,但分支未命中会使管道停滞,因此它们也会影响关键路径,在某种程度上,数据依赖项不在这里。这似乎是优化器的一个糟糕的决定。)
将条件重写为 offset[location] = (off>=3) ? off-1 : off+1; 说服 gcc 使用看起来不错的 asm 去无分支。
gcc7.1.1 -O3 -march=skylake(用于使用 (off <= 3) ? : -1 : +1 的分支编译的原始源代码)。
Performance counter stats for './ec-gcc':
70.032162 task-clock (msec) # 0.998 CPUs utilized
0 context-switches # 0.000 K/sec
0 cpu-migrations # 0.000 K/sec
118 page-faults # 0.002 M/sec
273,115,485 cycles # 3.900 GHz
255,088,412 instructions # 0.93 insn per cycle
44,382,466 branches # 633.744 M/sec
6,230,137 branch-misses # 14.04% of all branches
0.070181924 seconds time elapsed
对比CPython(Arch Linux 上的 Python3.6.2):
perf stat python ./orig-v2.e.py
21841249
Performance counter stats for 'python ./orig-v2.e.py':
3046.703831 task-clock (msec) # 1.000 CPUs utilized
10 context-switches # 0.003 K/sec
0 cpu-migrations # 0.000 K/sec
923 page-faults # 0.303 K/sec
11,880,130,860 cycles # 3.899 GHz
38,731,286,195 instructions # 3.26 insn per cycle
8,489,399,768 branches # 2786.421 M/sec
18,666,459 branch-misses # 0.22% of all branches
3.046819579 seconds time elapsed
抱歉,我没有安装 PyPy 或其他 Python 实现。