为什么__setitem__比cdef-classes的等效“普通”方法快得多?

问题描述 投票:5回答:2

看起来,对于Cython的cdef类,使用类特殊方法有时比相同的“通常”方法更快,例如__setitem__setitem快3倍:

%%cython
cdef class CyA:
    def __setitem__(self, index, val):
        pass
    def setitem(self, index, val):
        pass

现在:

cy_a=CyA()
%timeit cy_a[0]=3              # 32.4 ns ± 0.195 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
%timeit cy_a.setitem(0,3)      # 97.5 ns ± 0.389 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

这既不是Python的“正常”行为,特殊功能甚至更慢(并且比Cython等效的速度慢):

class PyA:
    def __setitem__(self, index, val):
        pass
    def setitem(self, index, val):
        pass

py_a=PyA()
%timeit py_a[0]=3           # 198 ns ± 2.51 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
%timeit py_a.setitem(0,3)   # 123 ns ± 0.619 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

在Cython中,所有特殊功能都不是这样的:

%%cython
cdef class CyA:
    ...
    def __len__(self):
        return 1
    def len(self):
        return 1

这导致:

cy_a=CyA()
%timeit len(cy_a)    #  59.6 ns ± 0.233 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
%timeit cy_a.len()   #  66.5 ns ± 0.326 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

即几乎相同的运行时间。

为什么__setitem__(...)比cdef-class中的setitem(...)快得多,即使两者都是cython化的?

python python-3.x performance cython
2个回答
3
投票

通用Python方法调用有相当多的开销 - Python查找相关属性(字典查找),确保属性是可调用对象,并且一旦调用它就处理结果。这种开销也适用于def类的通用cdef函数(唯一的区别在于该方法的实现在C中定义)。

但是,可以优化C / Cython类的特殊方法,如下所示:

查找速度

作为一种捷径,Python C API中的PyTypeObject定义了许多不同的“槽” - 特殊方法的直接函数指针。对于__setitem__,实际上有两个可用:PyMappingMethods.mp_ass_subscript对应于一般的“映射”调用,而PySequenceMethods.sq_ass_item,它允许你直接使用int作为索引器并对应于C API函数PySequence_SetItem

对于cdef class,Cython似乎只生成第一个(通用),因此加速不是直接传递C int。在生成非cdef类时,Cython不会填充这些插槽。

这些的优点是(对于C / Cython类)找到__setitem__ function just involves a couple of pointer NULL checks followed by a C function call。这也适用于__len__PyTypeObject也是由__setitem__的插槽定义的

相反,

  • 对于调用uses a default implementation的Python类,它改为"__setitem__",它为字符串cdef执行字典查找。
  • 对于调用非特殊def函数的setitem或Python类,从类/实例字典中查找该属性(速度较慢)

请注意,如果cdef class常规函数在cpdef中被定义为PyTypeObject(并且从Cython调用),那么Cython实现了自己的机制以便快速查找。

呼唤效率

找到属性后必须调用它。如果从__setitem__检索特殊函数(例如__len__上的cdef classPyObject),它们只是C函数指针,因此可以直接调用。

对于其他每种情况,必须对从属性查找中检索到的__setitem__进行评估以查看它是否可调用,然后调用。

退货处理

当从PyTypeObject调用__len__作为特殊函数时,返回值是一个int,它只是用作错误标志。不需要引用计数或处理Python对象。

PyTypeObject作为特殊函数从Py_ssize_t调用时,返回类型是setitem,必须转换为Python对象,然后在不再需要时销毁。

对于普通函数(例如,从Python或Cython类调用的__setitem__,或Python类中定义的PyObject*),返回值是%%cython cdef class CyA: # special functions def __setitem__(self, index, val): pass def __getitem__(self, index): pass ,必须适当地引用计数/销毁。


总之,差异实际上与查找和调用函数的快捷方式有关,而不是函数的内容是否是Cython化。


0
投票

@ DavidW的答案击中了头部的钉子,这里有一些更多的实验和细节证实了他的答案。

无论有多少参数,调用一个快速返回'Noone`的特殊函数:

a=CyA()  
%timeit a[0]    # 29.8 ns ± 1.9 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
%timeit a[0]=3  # 29.3 ns ± 0.942 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

现在

*args

功能的签名是已知的,没有必要构建**kwargs%%cython cdef class CyA: ... # normal functions: def fun0(self): pass def fun1(self, arg): pass def fun2(self, arg1, arg2): pass 。插槽中的查找速度和它一样快。

调用普通函数的开销取决于参数的数量:

a=CyA()  
...
%timeit a.fun0()     # 64.1 ns ± 2.49 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)     
%timeit a.fun1(1)    # 67.6 ns ± 0.785 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each) 
%timeit a.fun2(2,3)  # 94.7 ns ± 1.04 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

现在:

self

开销大于从槽中调用方法的开销,但如果有(至少)两个参数(不考虑65ns)也更大:95ns vs METH_NOARGS

原因是:cython-methods可以是以下类型之一

  1. self - 只有参数METH_O
  2. self - 只有METH_VARARGS|METH_KEYWORDS +一个参数
  3. fun2 - 具有任意数量的元素

方法*args是第三种类型,因此为了被称为​​Python,必须构造列表%%cython cdef class CyA: ... def __len__(self): return 1 # return 1000 would be slightly slower def len(self): return 1 ,这会导致额外的开销。

**从特殊方法返回可能会产生比正常方法更多的开销“:

a=CyA()
...  
%timeit len(a)   # 52.1 ns ± 1.57 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
%timeit a.len()  # 57.3 ns ± 1.39 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

导致:

__len__

正如@DavidW指出的那样,对于Py_ssize_t,在每次调用中,必须从返回的1构造一个“新的”int对象(在len()的情况下,它是来自池的整数,所以它不是真正构造的 - 但它是如果数字较大)。

这不是len()的情况:对于这个特殊的实现,Cython初始化一个全局对象,由__len__返回 - 增加引用计数器的成本并不高(与创建整数相比!)。

因此,len()和qazxswpoi的运行速度大致相同 - 但是时间用于不同的事情(创建整数与查找开销)。

© www.soinside.com 2019 - 2024. All rights reserved.