为什么__setitem__比cdef类的等效“常规”方法快得多?

时间:2018-11-28 21:55:09

标签: python python-3.x performance cython

对于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类中的setitem(...)快得多,即使两者都被cythonized了?

2 个答案:

答案 0 :(得分: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__的Python类,它代替uses a default implementation来对字符串"__setitem__"进行字典查找。

  • 对于cdef或调用非特殊def函数的Python类,从类/实例字典中查找该属性(速度较慢)

    < / li>

请注意,如果setitem常规函数将在cdef class中定义为cpdef(并从Cython调用),则Cython会实现自己的机制以进行快速查找。 / p>

通话效率

已找到必须调用的属性。从PyTypeObject中检索特殊功能的地方(例如__setitem__上的__len__cdef class),它们只是C函数指针,因此可以直接调用。

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

退货处理

当从__setitem__作为特殊函数调用PyTypeObject时,返回值是一个int,它简单地用作错误标志。无需引用计数或处理Python对象。

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

对于普通函数(例如,从Python或Cython类调用的setitem或在Python类中定义的__setitem__),返回值为PyObject*,必须对其进行引用计数/已适当销毁。


总而言之,区别实际上与查找和调用函数的快捷方式有关,而不是函数的内容是否被Cythonized

答案 1 :(得分:0)

@DavidW的答案令人震惊,这里有更多的实验和细节证实了他的答案。

无论有多少个参数,调用返回“ None”的特殊函数都是快速的:

%%cython
cdef class CyA:
# special functions
    def __setitem__(self, index, val):
        pass
    def __getitem__(self, index):
        pass

现在

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),开销也更大:65ns95ns

原因:cython方法可以是以下类型之一

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

方法fun2是第三种方法,因此要被调用,Python必须构造列表*args,这会导致额外的开销。

**从特殊方法返回可能比普通方法有更多开销”:

%%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)

正如@DavidW所指出的,对于__len__,在每次调用中,都必须根据返回的Py_ssize_t构造一个“新” int对象(对于1,它是一个池中的整数,因此它不是真正构造的-但在较大数字的情况下是这样的。

len()并非如此:对于这种特殊的实现,Cython初始化了一个全局对象,该对象由len()返回-增加引用计数器的成本不高(与创建一个整数!)。

因此,__len__len()的运行速度几乎相等-但时间花费在不同的事情上(创建整数与查找开销)。