【问题标题】:Interpretation vs dynamic dispatch penalty in PythonPython中的解释与动态调度惩罚
【发布时间】:2018-03-21 03:02:38
【问题描述】:

我看了 Brandon Rhodes 关于 Cython 的演讲——“EXE 的日子就在我们身上”。

Brandon 在 09:30 提到,对于特定的一小段代码,跳过解释可以提高 40% 的速度,而跳过分配和分派可以提高 574% 的速度 (10:10)。

我的问题是 - 这是如何衡量一段特定代码的?是否需要手动提取底层的 c 命令,然后以某种方式让运行时运行它们?

这是一个非常有趣的观察结果,但是我该如何重新创建实验呢?

【问题讨论】:

  • 这是本次演讲的源代码:github.com/brandon-rhodes/exe-from-python
  • 代码似乎与 cython/nuitka 相关,我没有看到任何可以显示 Brandon 最初声明的 sn-ps
  • 你试过用cython编译python的sn-ps吗?由于消除了 Brandon 在演讲中讨论的解释器开销,我通常预计使用 cython 编译纯 Python 应用程序的速度会提高约 50%。

标签: python dynamic-programming cython performance-testing dispatch


【解决方案1】:

我们来看看这个python函数:

def py_fun(i,N,step):
     res=0.0
     while i<N:
        res+=i
        i+=step
     return res

并使用 ipython-magic 来计时:

In [11]: %timeit py_fun(0.0,1.0e5,1.0)
10 loops, best of 3: 25.4 ms per loop

解释器将运行生成的字节码并对其进行解释。但是,我们可以通过使用 cython for/cythonizing 相同的代码来删除解释器:

%load_ext Cython
%%cython
def cy_fun(i,N,step):
     res=0.0
     while i<N:
        res+=i
        i+=step
     return res

我们为此获得了 50% 的加速:

In [13]: %timeit cy_fun(0.0,1.0e5,1.0)
100 loops, best of 3: 10.9 ms per loop

当我们查看生成的 c 代码时,我们看到直接调用了正确的函数,而无需解释/调用 ceval,这里是在剥离样板代码之后:

static PyObject *__pyx_pf_4test_cy_fun(CYTHON_UNUSED PyObject *__pyx_self, PyObject *__pyx_v_i, PyObject *__pyx_v_N, PyObject *__pyx_v_step) {
  ...
  while (1) {
    __pyx_t_1 = PyObject_RichCompare(__pyx_v_i, __pyx_v_N, Py_LT); 
    ...
    __pyx_t_2 = __Pyx_PyObject_IsTrue(__pyx_t_1);
    ...
    if (!__pyx_t_2) break;
    ...
    __pyx_t_1 = PyNumber_InPlaceAdd(__pyx_v_res, __pyx_v_i);
    ...
    __pyx_t_1 = PyNumber_InPlaceAdd(__pyx_v_i, __pyx_v_step); 
  }
  ...
  return __pyx_r;
}

然而,这个 cython 函数处理 python 对象而不是 c 样式的浮点数,所以在函数 PyNumber_InPlaceAdd 中,有必要弄清楚这些对象(整数、浮点数、其他什么?)到底是什么并调度调用正确的函数来完成这项工作。

在 cython 的帮助下,我们还可以消除这种调度的需要并直接调用浮点数的乘法:

 %%cython
 def c_fun(double i,double N, double step):
      cdef double res=0.0
      while i<N:
         res+=i
         i+=step
      return res

在这个版本中,iNstepres 是 c 风格的双精度对象,不再是 python 对象。所以不再需要像PyNumber_InPlaceAdd这样调用dispatch-functions,而是可以直接调用+-operator for double

static PyObject *__pyx_pf_4test_c_fun(CYTHON_UNUSED PyObject *__pyx_self, double __pyx_v_i, double __pyx_v_N, double __pyx_v_step) {
  ...
  __pyx_v_res = 0.0;  
  ... 
  while (1) {
    __pyx_t_1 = ((__pyx_v_i < __pyx_v_N) != 0);
    if (!__pyx_t_1) break;
    __pyx_v_res = (__pyx_v_res + __pyx_v_i);
    __pyx_v_i = (__pyx_v_i + __pyx_v_step);
  }
  ...
  return __pyx_r;
}

结果是:

In [15]: %timeit c_fun(0.0,1.0e5,1.0)
10000 loops, best of 3: 148 µs per loop

现在,与没有解释器但有调度的版本相比,这几乎提高了 100 倍。

实际上,说 dispatch+allocation 是这里的瓶颈(因为消除它会导致几乎 100 倍的加速)是一个谬误:解释器负责超过 50% 的运行时间(15 ms) 并“仅”调度和分配 10ms。


但是,性能上除了“解释器”和动态调度之外还有更多问题:Float 是不可变的,因此每次更改时都必须在垃圾收集器中创建和注册/注销新对象。

我们可以引入可变浮点数,它们在原地改变,不需要注册/注销:

%%cython
cdef class MutableFloat: 
 cdef double x      
 def __cinit__(self, x):
    self.x=x         
 def __iadd__(self, MutableFloat other):
    self.x=self.x+other.x
    return self
 def __lt__(MutableFloat self,  MutableFloat other):
    return self.x<other.x
 def __gt__(MutableFloat self, MutableFloat other):
    return self.x>other.x
 def __repr__(self):
    return str(self.x)

时间安排(现在我用的是不同的机器,所以时间安排有点不同):

def py_fun(i,N,step,acc):
        while i<N:
             acc+=i
             i+=step
        return acc

%timeit py_fun(1.0, 5e5,1.0,0.0)
30.2 ms ± 1.12 ms per loop (mean ± std. dev. of 7 runs, 10 loops each 
%timeit cy_fun(1.0, 5e5,1.0,0.0)
16.9 ms ± 612 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit i,N,step,acc=MutableFloat(1.0),MutableFloat(5e5),MutableFloat(1
    ...: .0),MutableFloat(0.0); py_fun(i,N,step,acc)
23 ms ± 254 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%timeit i,N,step,acc=MutableFloat(1.0),MutableFloat(5e5),MutableFloat(1
...: .0),MutableFloat(0.0); cy_fun(i,N,step,acc)
11 ms ± 66.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

不要忘记重新初始化i,因为它是可变的!结果

            immutable       mutable
 py_fun       30ms           23ms
 cy_fun       17ms           11ms

因此,在带有解释器的版本中注册/取消注册浮点数最多需要 7 毫秒(大约 20%)(我不确定是否有其他东西在起作用),而在没有解释器的版本中则需要超过 33%口译员。

现在的样子:

  • 40% (13/30) 的时间由口译员使用
  • 多达 33% 的时间用于动态调度
  • 最多 20% 的时间用于创建/删除临时对象
  • 大约 1% 用于算术运算

另一个问题是数据的局部性,这对于内存带宽限制问题变得很明显:如果数据线性处理一个接一个连续的内存地址,现代缓存工作得很好。这适用于循环 std::vector&lt;&gt;(或 array.array),但不适用于循环 python 列表,因为该列表由可以指向内存中任何位置的指针组成。

考虑以下 python 脚本:

#list.py
N=int(1e7)
lst=[0]*int(N)
for i in range(N):
  lst[i]=i
print(sum(lst)) 

#byte
N=int(1e7)
b=bytearray(8*N)
m=memoryview(b).cast('L') #reinterpret as an array of unsigned longs
for i in range(N):
  m[i]=i
print(sum(m))

它们都创建1e7 整数,第一个版本是 Python 整数,第二个版本是连续放置在内存中的低级 c-int。

有趣的是,这些脚本产生了多少缓存未命中 (D):

valgrind --tool=cachegrind python list.py 
...
D1  misses:        33,964,276  (   27,473,138 rd   +     6,491,138 wr)

valgrind --tool=cachegrind python bytearray.py 
...
D1  misses:         4,796,626  (    2,140,357 rd   +     2,656,269 wr)

这意味着 python 整数的缓存未命中次数增加了 8 倍。部分原因是因为 python 整数需要超过 8 个字节(可能是 32 个字节,即因子 4)的内存和(也许,不是 100% 肯定,因为相邻的整数是一个接一个地创建的,所以机会很高,它们在内存中的某个位置一个接一个地存储,需要进一步调查)一些原因是它们在内存中没有对齐,因为bytearray的c整数就是这种情况。

【讨论】:

  • 这似乎是正确的 :) 您能否添加已编译的 cython 文件的位置以确保完整性?
  • @Jay 我猜同时你已经发现临时文件在~/.ipython/cython
  • @Jay btw,从“解释器 40% 加速,调度 60 倍加速”开始,调度是瓶颈是一个谬论:它们都需要大约 10 毫秒,所以两者都同样“糟糕”或“好的”。请参阅我更新的答案以及您可能会感兴趣的更多实验。
猜你喜欢
  • 2022-06-11
  • 2019-07-10
  • 2011-04-16
  • 1970-01-01
  • 2016-07-04
  • 1970-01-01
  • 1970-01-01
  • 2021-11-17
  • 1970-01-01
相关资源
最近更新 更多