【问题标题】:PyPy 17x faster than Python. Can Python be sped up?PyPy 比 Python 快 17 倍。 Python可以加速吗?
【发布时间】:2018-05-19 21:04:49
【问题描述】:

解决最近的Advent of Code problem,我发现我的默认 Python 比 PyPy 慢约 40 倍。通过限制对 len 的调用并通过在函数中运行它来限制全局查找,我能够使用 this code 将其降低到大约 17 倍。

现在,e.py 在我机器上的 python 3.6.3 和 PyPy 上的运行时间为 5.162 秒。

我的问题是:这是 JIT 不可减少的加速,还是有什么方法可以加速 CPython 的答案? (没有极端的意思:我可以去 Cython/Numba 什么的?)我如何说服自己我无能为力?


有关数字输入文件列表,请参阅要点。

the problem statement 中所述,它们表示跳转偏移量。 position += offsets[current],并将当前偏移量增加 1。当跳转将您带到列表之外时,您就完成了。

这是给出的示例(需要 5 秒的完整输入要长得多,并且数字更大):

(0) 3  0  1  -3  - before we have taken any steps.
(1) 3  0  1  -3  - jump with offset 0 (that is, don't jump at all). Fortunately, the instruction is then incremented to 1.
 2 (3) 0  1  -3  - step forward because of the instruction we just modified. The first instruction is incremented again, now to 2.
 2  4  0  1 (-3) - jump all the way to the end; leave a 4 behind.
 2 (4) 0  1  -2  - go back to where we just were; increment -3 to -2.
 2  5  0  1  -2  - jump 4 steps forward, escaping the maze.

代码:

def run(cmds):
    location = 0
    counter = 0
    while 1:
        try:
            cmd = cmds[location]
            if cmd >= 3:
                cmds[location] -= 1
            else:
                cmds[location] += 1
            location += cmd
            if location < 0:
                print(counter)
                break
            counter += 1
        except:
            print(counter)
            break

if __name__=="__main__":
    text = open("input.txt").read().strip().split("\n")
    cmds = [int(cmd) for cmd in text]
    run(cmds)

编辑:我使用 Cython 编译并运行代码,运行时间降低到 2.53 秒,但这仍然比 PyPy 慢了一个数量级。

编辑:Numba gets me to within 2x

编辑:最好的 Cython I could write 下降到 1.32 秒,略高于 4 倍 pypy

编辑:按照@viraptor 的建议,将cmd 变量移动到cdef 中,将Cython 降低到0.157 秒!快了近一个完整的数量级,并且与常规 python 相差不远。尽管如此,PyPy JIT 给我留下了深刻的印象,它完全免费!

【问题讨论】:

  • 来自问题陈述:一个紧急中断从 CPU 到达:它陷入了跳转指令的迷宫中, 显然 glib 的答案是:不要将 IRQ 处理程序写入蟒蛇:P
  • 我对 Python(或不同实现的性能)不是很熟悉,但是我希望 JIT 对 CPU 密集型循环有很大的帮助像这样。对我来说似乎是一个有趣的问题;赞成。也许您应该快速总结问题所在,而不仅仅是外部链接。
  • 你能把try放在循环周围,这样解释器就不必每次循环都输入一个新的try块吗?
  • 至少,检查一下并确保你检查了the well known但这属于CodeReview
  • @PeterCordes 将尝试移出循环会产生轻微的颠簸,运行时间降至 4.8 秒????

标签: python performance benchmarking pypy


【解决方案1】:

作为 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,内循环是无分支的;它对&gt;=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 &lt; 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 的延迟,并且避免了addindex += 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&gt;=3) ? off-1 : off+1; 说服 gcc 使用看起来不错的 asm 去无分支。

gcc7.1.1 -O3 -march=skylake(用于使用 (off &lt;= 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 实现。

【讨论】:

    【解决方案2】:

    你可以改进一些小事情,但是 pypy 在这个任务中(很可能)总是更快。

    对于 CPython 和 Cython:

    • 不要一次读入整个文件。您可以随时迭代线路,从而节省(重新)分配成本。不过,这需要您预先分配数组。
    • 可能从列表切换到array

    对于 Cython:

    • Mark the variable types。尤其是 ints 的计数器和 ints 数组的命令可以跳过大多数类型检查。

    【讨论】:

    • 读取文件与运行时无关,这一切都在开始时以恒定成本发生。不过你是对的
    • 做到 1.3 秒。关闭!速度损失不到 5 倍,但离 python 越来越远:)
    • @llimllib 你可以做得更好:在列表理解中重命名cmd,这样它就不会与while 中的那个冲突。然后在循环之前添加一个cdef int cmd = 0。否则,每个cmd 都被构造为一个对象。
    • @llimllib cython 实际上在提供所有类型的情况下都击败了 pypy:来自要点的 pyx:1 loop, best of 3: 1.23 s per loop,在最后一条评论的修改后:10 loops, best of 3: 68.3 ms per loop(两者都使用-O3 编译为 python3。 6)
    • 啊,好点,不敢相信我错过了那个 var! cython 低至 0.157 秒,令人印象深刻!
    猜你喜欢
    • 1970-01-01
    • 2016-04-28
    • 1970-01-01
    • 2012-09-16
    • 2014-03-28
    • 2011-01-09
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多