【问题标题】:Why python broadcasting in the example below is slower than a simple loop?为什么下面示例中的 python 广播比简单的循环慢?
【发布时间】:2020-07-31 05:36:53
【问题描述】:

我有一个向量数组,并计算它们与第一个向量的差异范数。 使用 python 广播时,计算比通过简单循环进行的计算要慢得多。为什么?

import numpy as np

def norm_loop(M, v):
  n = M.shape[0]
  d = np.zeros(n)
  for i in range(n):
    d[i] = np.sum((M[i] - v)**2)
  return d

def norm_bcast(M, v):
  n = M.shape[0]
  d = np.zeros(n)
  d = np.sum((M - v)**2, axis=1)
  return d

M = np.random.random_sample((1000, 10000))
v = M[0]

%timeit norm_loop(M, v) 
25.9 ms

%timeit norm_bcast(M, v)
38.5 ms

我有 Python 3.6.3 和 Numpy 1.14.2

在 google colab 中运行示例: https://drive.google.com/file/d/1GKzpLGSqz9eScHYFAuT8wJt4UIZ3ZTru/view?usp=sharing

【问题讨论】:

    标签: python numpy


    【解决方案1】:

    发挥最大性能

    在矢量化操作中,您显然有一个糟糕的缓存行为。但是由于没有利用现代 SIMD 指令(AVX2,FMA),计算本身也很慢。幸运的是,克服这个问题并不复杂。

    示例

    import numpy as np
    import numba as nb
    @nb.njit(fastmath=True,parallel=True)
    def norm_loop_improved(M, v):
      n = M.shape[0]
      d = np.empty(n,dtype=M.dtype)
    
      #enables SIMD-vectorization 
      #if the arrays are not aligned
      M=np.ascontiguousarray(M)
      v=np.ascontiguousarray(v)
    
      for i in nb.prange(n):
        dT=0.
        for j in range(v.shape[0]):
          dT+=(M[i,j]-v[j])*(M[i,j]-v[j])
        d[i]=dT
      return d
    

    性能

    M = np.random.random_sample((1000, 1000))
    norm_loop_improved: 0.11 ms**, 0.28ms
    norm_loop: 6.56 ms 
    norm_einsum: 3.84 ms
    
    M = np.random.random_sample((10000, 10000))
    norm_loop_improved:34 ms
    norm_loop: 223 ms
    norm_einsum: 379 ms
    

    ** 衡量性能时要小心

    第一个结果(0.11ms)来自使用相同数据重复调用函数。这需要 77 GB/s 的 RAM 读取吞吐量,这远远超过我的 DDR3 双通道 RAM 的能力。由于连续调用具有相同输入参数的函数根本不现实,我们必须修改测量。

    为了避免这个问题,我们必须使用不同的数据调用相同的函数至少两次(8MB 三级缓存,8MB 数据),然后将结果除以 2 以清除所有缓存。

    这些方法的相对性能在数组大小上也有所不同(查看 einsum 结果)。

    【讨论】:

      【解决方案2】:

      内存访问。

      首先,广播版本可以简化为

      def norm_bcast(M, v):
           return np.sum((M - v)**2, axis=1)
      

      这仍然比循环版本运行稍慢。 现在,传统观点认为使用广播的矢量化代码应该总是更快,这在许多情况下是不正确的(我会无耻地插入我的另一个答案here)。那么发生了什么?

      正如我所说,它归结为内存访问。

      在广播版本中,从 v 中减去 M 的每个元素。在处理 M 的最后一行时,处理第一行的结果已从缓存中逐出,因此对于第二步,这些差异再次加载到高速缓存和平方。最后,它们被加载并第三次处理以进行求和。由于 M 相当大,因此在每一步都会清除部分缓存以容纳所有数据。

      在循环版本中,每一行都在一个较小的步骤中完全处理,从而减少了缓存未命中和整体更快的代码。

      最后,使用einsum 可以通过一些数组操作来避免这种情况。 此函数允许混合矩阵乘法和求和。 首先,我要指出,与 numpy 的其余部分相比,它的语法相当不直观,而且潜在的改进通常不值得付出额外的努力来理解它。 由于舍入误差,答案也可能略有不同。 在这种情况下,它可以写成

      def norm_einsum(M, v):
          tmp = M-v
          return np.einsum('ij,ij->i', tmp, tmp)
      

      这将其减少为对整个数组的两个操作 - 减法和调用 einsum,它执行平方和求和。 这带来了轻微的改进:

      %timeit norm_bcast(M, v)
      30.1 ms ± 116 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
      
      %timeit norm_loop(M, v)
      25.1 ms ± 37.3 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
      
      %timeit norm_einsum(M, v)
      21.7 ms ± 65.3 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
      

      【讨论】:

      • 如果您在 norm_loop 中更改为d[i] = np.linalg.norm(M[i] - v),您可以节省更多时间。奇怪的是,我用 einsum 得到了较慢的结果。不过可能是因为 OpenBLAS。
      猜你喜欢
      • 2018-03-25
      • 2020-10-14
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2015-10-07
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多