2024-05-16 10:58:55 发布
网友
我看了布兰登·罗德斯关于赛顿的谈话-“EXE的日子就要到了”。在
Brandon在09:30提到,对于一个特定的短代码段,跳过解释会带来40%的加速,而跳过分配和分派则会带来574%的加速(10:10)。在
我的问题是——对于一段特定的代码,这是如何衡量的?是否需要手动提取底层c命令,然后让运行时运行它们?在
这是一个非常有趣的观察,但是我如何重现这个实验呢?在
让我们看看这个python函数:
def py_fun(i,N,step): res=0.0 while i<N: res+=i i+=step return res
使用ipython魔法计时:
解释器将运行产生的字节码并对其进行解释。但是,我们可以通过使用cython来/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:
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样式的float,因此在函数PyNumber_InPlaceAdd中,有必要弄清楚这些对象是什么(integer、float、其他什么?)真的是,并将此调用分派到正确的函数中。在
PyNumber_InPlaceAdd
借助于cython,我们还可以消除这种调度的需要,直接调用float的乘法:
%%cython def c_fun(double i,double N, double step): cdef double res=0.0 while i<N: res+=i i+=step return res
在这个版本中,i、N、step和{}是c风格的双精度函数,不再是python对象。因此,不再需要调用PyNumber_InPlaceAdd这样的分派函数,但我们可以直接为double调用+-operator:
i
N
step
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毫秒),而调度和分配“只”负责10毫秒
然而,在性能上,除了解释器和动态调度之外,还有更多的问题: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
因此,在有解释器的版本中,注册/注销float最多需要7毫秒(约20%),而在没有解释器的版本中则需要超过33%。在
现在看来:
另一个问题是数据的局部性,这一点对于内存带宽限制问题来说是显而易见的:如果数据一个接一个地线性地处理,那么现代缓存就可以很好地工作。对于在std::vector<>(或array.array)上循环是正确的,但是对于python列表的循环则不是这样,因为这个列表包含可以指向内存中任何位置的指针。在
std::vector<>
array.array
考虑以下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。在
1e7
有趣的是,这些脚本会产生多少缓存未命中(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-整数。在
bytearray
让我们看看这个python函数:
使用ipython魔法计时:
^{pr2}$解释器将运行产生的字节码并对其进行解释。但是,我们可以通过使用cython来/cythonizing相同的代码来删除解释器:
我们的速度提高了50%:
当我们查看生成的c代码时,我们发现在这里,在剥离样板代码之后,可以直接调用正确的函数,而不需要解释/调用
ceval
:但是,这个cython函数处理python对象,而不是c样式的float,因此在函数
PyNumber_InPlaceAdd
中,有必要弄清楚这些对象是什么(integer、float、其他什么?)真的是,并将此调用分派到正确的函数中。在借助于cython,我们还可以消除这种调度的需要,直接调用float的乘法:
在这个版本中,}是c风格的双精度函数,不再是python对象。因此,不再需要调用
i
、N
、step
和{PyNumber_InPlaceAdd
这样的分派函数,但我们可以直接为double
调用+
-operator:结果是:
现在,与没有解释器但有调度的版本相比,这一速度提高了近100。在
实际上,可以说,dispatch+allocation是这里的瓶颈(因为消除它会导致几乎100倍的加速)是一个谬论:解释程序负责50%以上的运行时间(15毫秒),而调度和分配“只”负责10毫秒
然而,在性能上,除了解释器和动态调度之外,还有更多的问题:Float是不可变的,所以每次它改变时都必须创建一个新的对象并在垃圾收集器中注册/注销。在
我们可以引入可变浮动,这些浮动是就地更改的,不需要注册/注销:
时间安排(现在我使用不同的机器,所以时间安排有点不同):
不要忘记重新初始化
i
,因为它是可变的!结果因此,在有解释器的版本中,注册/注销float最多需要7毫秒(约20%),而在没有解释器的版本中则需要超过33%。在
现在看来:
另一个问题是数据的局部性,这一点对于内存带宽限制问题来说是显而易见的:如果数据一个接一个地线性地处理,那么现代缓存就可以很好地工作。对于在
std::vector<>
(或array.array
)上循环是正确的,但是对于python列表的循环则不是这样,因为这个列表包含可以指向内存中任何位置的指针。在考虑以下python脚本:
以及
它们都创建
1e7
整数,第一个版本是Python整数,第二个版本是连续放置在内存中的低级c-int。在有趣的是,这些脚本会产生多少缓存未命中(D):
与
这意味着python缓存中的8个整数会更多。部分原因是,python整数需要超过8个字节(可能是32个字节,即因子4)的内存和(也许,不是100%确定,因为相邻的整数是在一个又一个后面创建的,所以可能性很高,它们被存储在内存中的某个地方,需要进一步的调查)一些原因是,它们在内存中没有对齐
bytearray
的c-整数。在相关问题 更多 >
编程相关推荐