【问题标题】:Why is this improved sieve slower with pypy?为什么使用 pypy 这个改进的筛子会变慢?
【发布时间】:2017-06-28 19:51:26
【问题描述】:
def sieve(n):
    nums = [0] * n
    for i in range(2, int(n**0.5)+1):
        if nums[i] == 0:
            for j in range(i*i, n, i):
                nums[j] = 1

    return [i for i in range(2, n) if nums[i] == 0]

def sieve_var(n):
    nums = [0] * n
    for i in range(3, int(n**0.5)+1, 2):
        if nums[i] == 0:
            for j in range(i*i, n, i):
                nums[j] = 1

    return [2] + [i for i in range(3, n, 2) if nums[i] == 0]

在我的机器上,sieve(10**8) 需要 2.28 秒,而sieve_var(10**8) 需要 2.67 秒。我不认为 pypy 的预热时间是这里的罪魁祸首,那么为什么不是 sieve_var,它迭代得更少、更快?在标准 python 3.3 中,sieve_var 比预期的更快。在 Windows 8.1 上使用 pypy 4.0.1 32bit。

编辑:作为测试,我在函数的开头添加了count = 0,并在内部循环中添加了count += 1nums[j] = 1 所在的位置)。 sieve(10**8) 计数为 242570202,而 sieve_var(10**8) 计数为 192570204。因此,尽管 sieve_var 的计数没有减半,但它做的“工作”更少。

为了好玩,这里有一个带有切片索引的版本:

def sieve_slice(n):
    sieve = [True] * n
    for i in range(3,int(n**0.5)+1,2):
        if sieve[i]:
            sieve[i*i::2*i]=[False]*((n-i*i-1)//(2*i)+1)
    return [2] + [i for i in range(3,n,2) if sieve[i]]

使用 python 3.6,sieve_slice 的运行速度比 sieve 快大约 4 倍,但使用 pypy3 7.3.0,sieve 的运行速度大约是 sieve_slice 的 2 倍。

【问题讨论】:

  • @user2357112 如果错了那为什么sieve(10**8) == sieve_var(10**8)
  • 你是怎么计时的?
  • @user2357112 基本上是t = time.clock(); print(len(sieve(10**8))); print(time.clock()-t)
  • 你是否在同一个进程中对这两个调用计时?
  • 我可以重现该问题。 len(sieve(2*10**8)) 大约 6.6 秒,len(sieve_var(2*10**8)) 大约 7 秒。我在 64 位 Windows 上使用 pypy 5.7.1。

标签: python performance pypy


【解决方案1】:

我不确定为什么它在 Windows 上会稍微慢一些。在 Linux 上,速度是相同的。不过,我可以回答为什么我们大部分的速度是一样的。如果程序是用 C 编写的,答案将是相同的,答案纯粹是在处理器级别。该程序绑定在访问列表的内存 I/O 上,该列表的大小为 400 或 800MB。在第二个版本中,您基本上避免了额外的if nums[i] == 0 检查。不过,这个额外的检查没有任何成本,因为 CPU 在上一次迭代期间只是在其缓存中获取了nums[i - 1],并且在下一次迭代期间将需要nums[i + 1]。无论如何,CPU 都在等待内存。

要验证我的意思,请尝试使 nums 数组更紧凑。我尝试用nums[i // 2] 访问它,假设i 总是奇怪的,结果快了一倍。不使用 Python 列表(在 32 位 PyPy 上存储为 32 位整数数组),而是使用位数组(但代码更多,因为没有标准的内置位数组)。

【讨论】:

  • 在 sieve_var 情况下,内循环的迭代次数要少得多。试试这个代码:bpaste.net/show/bfbd7e66bdcb
  • 额外支票?第二个版本不是做一半的检查吗?
  • 是的,第二个版本理论上只做一半的检查。我的观点是它完全无关紧要,因为这些检查都在缓存中,并且基准测试是受内存限制的。有人告诉我,通过使用 bytearray,它的速度提高了 7 倍(在 64 位 linux 上)——而且,令人惊讶的是,这几乎是内存的减少。
  • 我认为这不是正确的解释——我已将此代码更改为使用bytearray;现在在我的家庭服务器(Linux)上sievesieve_var 使用 python3 分别需要 55 和 39 秒,但 5.29 和 5.53 使用 pypy。所以, sieve_var 在 pypy 下还是比较慢的。
  • @qwr 如果你在没有 jit-compiler (pypy --jit off) 的情况下运行 pypy,你会看到 sieve_varsieve 快。我想这是解释器的开销,这会有所不同。
【解决方案2】:

TL,DR;

作为一个 C 程序,这将是一个内存绑定算法。然而,即使是 jit 编译的 pypy 代码也有相当多的开销,并且这些操作不再是“免费的”。令人惊讶的是(或者可能不是),这两个 sieve 版本有不同的 jit-code,可能只是运气不好,第二个版本导致代码变慢。


如果是 C,@Armin 的答案将是正确的。众所周知,对于现代计算机/缓存和内存绑定代码,如果我们跳过一个整数并不重要 - 但是所有值都必须从内存中获取,这是一个瓶颈。请参阅this article 以获得很好的解释。

但我的实验表明,非优化版本 (sieve) 比优化版本 (sieve_var) 稍快。时序还显示,sieve 的最后一行,即[i for i in range(2, n) if nums[i] == 0] 的执行速度比sieve_var - return [2] + [i for i in range(3, n, 2) if nums[i] == 0] 的行快。

在我的机器上是0.45 秒,而0.65 秒为10^8 元素。这些数字可能因机器而异,因此 CPU 更快、内存更慢的人很可能根本看不到任何差异。如果可以用“内存主导一切”来解释,那么我们应该可以看到,速度较慢的版本的缓存未命中率比速度快的版本要多。

但是,通过运行valgrind --tool=cachegrind pypy sieveXXX.py,我们可以看到,缓存未命中的数量几乎没有差异,至少没有什么可以解释可观察到的差异。

让我们考虑一个稍微简单的版本,它表现出完全相同的行为 - 我们不保存素数,而只是计算它们:

def sieve(n):
    ...
    res=0
    for i in range(2, n): 
          if nums[i] == 0:
              res+=1
    return res

def sieve_var(n):
    ...
    res=1
    for i in range(3, n,2): 
          if nums[i] == 0:
              res+=1
    return res

第一个版本仍然更快:0.35 秒。与0.45 sec 相比(为了确保时间差不是侥幸而不是由于一些 jit-warmup,我将代码的最后一部分放入 for 循环并始终得到相同的时间)。

在继续之前,让我们看一下 C 实现及其程序集

long long int sum(long long int *a, int n){
    long long int res=0;
    for(int i=2;i<n;i++)
       if(a[i]==0)
          res++;
    return res;
} 

gcc and -Os we get编译:

        movl    $2, %edx
        xorl    %eax, %eax
.L4:
        cmpl    %edx, %esi
        jle     .L1
        cmpq    $0, (%rdi,%rdx,8)
        jne     .L3
        incq    %rax
.L3:
        incq    %rdx
        jmp     .L4
.L1:
        ret

非常小而直接,在我的机器上只需要0.08 秒。我的内存可以达到 10 GB/s,并且有 8*10^8 字节 - 所以基本上需要整个时间来获取数据。

但从这里我们也看到,与 C 代码相比,pypy 版本的开销约为 0.25 秒。它从何而来?通过使用vmprof-module,我们可以看到 jit 代码:

  1. 对于循环的一次迭代,操作比 C 版本中的要多得多
  2. sievesieve_par 的版本非常不同。我们可以use debugger to count the number of instruction 的迭代:24 用于sieve76-操作用于sieve_var,它只处理每个第二个元素,所以关系实际上是24:38

很难说,为什么两个版本的 jit-code 在没有调试 pypy 的情况下如此不同。可能只是运气不好,sieve_par 速度较慢。

【讨论】:

  • 你认为程序会受到列表 I/O 速度的限制吗?
  • 您的回答纯属推测。如果您描述的是 PyPy,那么这是错误的,抱歉。
  • @ArminRigo 你说得对,这只是尝试解释实验结果,因为它们不能仅从内存带宽+缓存的角度来解释。非常感谢所有纠正我误解的人!
  • 在这种无聊的例子中,PyPy 将列表实现为(机器大小的)整数数组,并对所有整数进行拆箱。这意味着 PyPy 的 JIT 生成的代码接近于 C 的代码,不同之处主要在于缺少 gcc 更好的低级优化。也许有人应该首先尝试编写实际的 C 代码,以检查我们是否确实得到不同的行为;这听起来像是在假设我们会有所作为并猜测原因之前应该做的事情。
  • 我不知道哪个答案可以标记为“正确”,但我愿意为您提供确定 pypy 与 C 差异的实验性工作的奖励。
猜你喜欢
  • 1970-01-01
  • 2012-11-05
  • 2017-10-05
  • 2011-06-03
  • 2012-12-24
  • 2013-06-06
  • 1970-01-01
  • 2022-07-29
  • 1970-01-01
相关资源
最近更新 更多