为什么这种改良的筛子会因为pypy而变慢?

时间:2017-06-28 19:51:26

标签: python performance pypy

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 32位。

编辑:作为测试,我在函数的开头添加了count = 0,在内部循环中添加了count += 1(其中nums[j] = 1是)。 sieve(10**8)计算为242570202,而sieve_var(10**8)计为192570204.因此虽然sieve_var的计数不会减半,但它的工作量却减少了#34;工作量为#34;

2 个答案:

答案 0 :(得分:10)

我不确定为什么它在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位整数数组)来赢得更多,而是一个位数组(但它的代码更多,因为没有标准的内置位数组。)

答案 1 :(得分:4)

TL,DR;

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

如果是C,@ Armin的回答是正确的。众所周知,对于现代计算机/缓存和内存绑定代码,如果我们跳过一个整数并不重要 - 尽管如此,所有值都必须从内存中获取,这是一个瓶颈。有关详细说明,请参阅this article

然而,我的实验表明,非优化版本(sieve)比优化版本(sieve_var)略快。 Timings还表明,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秒相比(为了确保时间差异不是侥幸而不是由于某些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-code和:

  1. 对于循环的一次迭代,有比C-version
  2. 更多的操作
  3. sievesieve_par的版本非常不同。我们可以use debugger to count the number of instruction进行迭代:24用于sieve76 - sieve_var的操作仅处理每一个元素,因此关系实际为{{} 1}}。
  4. 很难说,为什么jit-code对于没有调试pypy的两个版本都是如此不同。可能只是运气不好,24:38速度较慢。