python - 在python中,为什么子类化这么慢?

  显示原文与译文双语对照的内容

我在开发一个扩展dict的简单类,我意识到键查找和使用pickle非常慢。

我以为类有问题,所以我做了一些简单的基准测试:


(venv) marco@buzz:~/sources/python-frozendict/test$ python --version


Python 3.9.0a0


(venv) marco@buzz:~/sources/python-frozendict/test$ sudo pyperf system tune --affinity 3


[sudo] password for marco: 


Tune the system configuration to run benchmarks



Actions


=======



CPU Frequency: Minimum frequency of CPU 3 set to the maximum frequency



System state


============



CPU: use 1 logical CPUs: 3


Perf event: Maximum sample rate: 1 per second


ASLR: Full randomization


Linux scheduler: No CPU is isolated


CPU Frequency: 0-3=min=max=2600 MHz


CPU scaling governor (intel_pstate): performance


Turbo Boost (intel_pstate): Turbo Boost disabled


IRQ affinity: irqbalance service: inactive


IRQ affinity: Default IRQ affinity: CPU 0-2


IRQ affinity: IRQ affinity: IRQ 0,2=CPU 0-3; IRQ 1,3-17,51,67,120-131=CPU 0-2


Power supply: the power cable is plugged



Advices


=======



Linux scheduler: Use isolcpus=<cpu list> kernel parameter to isolate CPUs


Linux scheduler: Use rcu_nocbs=<cpu list> kernel parameter (with isolcpus) to not schedule RCU on isolated CPUs


(venv) marco@buzz:~/sources/python-frozendict/test$ python -m pyperf timeit --rigorous --affinity 3 -s ' 


x = {0:0, 1:1, 2:2, 3:3, 4:4}


' 'x[4]'


.........................................


Mean +- std dev: 35.2 ns +- 1.8 ns


(venv) marco@buzz:~/sources/python-frozendict/test$ python -m pyperf timeit --rigorous --affinity 3 -s '


class A(dict):


 pass 



x = A({0:0, 1:1, 2:2, 3:3, 4:4})


' 'x[4]'


.........................................


Mean +- std dev: 60.1 ns +- 2.5 ns


(venv) marco@buzz:~/sources/python-frozendict/test$ python -m pyperf timeit --rigorous --affinity 3 -s '


x = {0:0, 1:1, 2:2, 3:3, 4:4}


' '5 in x'


.........................................


Mean +- std dev: 31.9 ns +- 1.4 ns


(venv) marco@buzz:~/sources/python-frozendict/test$ python -m pyperf timeit --rigorous --affinity 3 -s '


class A(dict):


 pass



x = A({0:0, 1:1, 2:2, 3:3, 4:4})


' '5 in x'


.........................................


Mean +- std dev: 64.7 ns +- 5.4 ns


(venv) marco@buzz:~/sources/python-frozendict/test$ python


Python 3.9.0a0 (heads/master-dirty:d8ca2354ed, Oct 30 2019, 20:25:01) 


[GCC 9.2.1 20190909] on linux


Type"help","copyright","credits" or"license" for more information.


>>> from timeit import timeit


>>> class A(dict):


... def __reduce__(self): 


... return (A, (dict(self), ))


... 


>>> timeit("dumps(x)","""


... from pickle import dumps


... x = {0:0, 1:1, 2:2, 3:3, 4:4}


...""", number=10000000)


6.70694484282285


>>> timeit("dumps(x)","""


... from pickle import dumps


... x = A({0:0, 1:1, 2:2, 3:3, 4:4})


...""", number=10000000, globals={"A": A})


31.277778962627053


>>> timeit("loads(x)","""


... from pickle import dumps, loads


... x = dumps({0:0, 1:1, 2:2, 3:3, 4:4})


...""", number=10000000)


5.767975459806621


>>> timeit("loads(x)","""


... from pickle import dumps, loads


... x = dumps(A({0:0, 1:1, 2:2, 3:3, 4:4}))


...""", number=10000000, globals={"A": A})


22.611666693352163



结果真是意外,虽然键查找速度较慢2x,但pickle速度较慢5x。

get()__eq__()__init__(),以及keys()上的迭代速度如何。

编辑:我查看了python 3.9的源代码,它是Objects/dictobject.c,而dict_subscript()仅在缺少键时减慢子类,因为子类可以实现__missing__(),并且它尝试查看它是否存在,但是基准测试中有一个现有的密钥。

注意到:__getitem__()是用标志METH_COEXIST定义的,另外,__contains__()是另一个2x慢的方法,有相同的标志,从官方文档

方法被加载来代替现有的定义,没有METH_COEXIST,缺省值是跳过重复的定义,由于slot包装器是在方法表之前加载的,例如sq_contains slot的存在将生成一个contains ()的包装方法,并阻止加载有相同名称的相应PyCFunction,定义了标志后,将加载PyCFunction来代替包装对象,并将与插槽共存,这很有帮助,因为对PyCFunctions的调用比对包装器对象调用的优化多。

如果我理解正确,理论上METH_COEXIST应该加快速度,但它有相反的效果,为什么?

EDIT 2:我发现了更多。

__getitem__()__contains()__被标记为METH_COEXIST,因为它们在PyDict_Type中声明了两次。

它们在插槽tp_methods中同时存在,其中它们被显式声明为__getitem__()__contains()__,但是官方文档表示tp_methods不是由子类继承的。

dict的子类不调用__getitem__(),而是调用subslot mp_subscript,实际上,mp_subscript包含在插槽tp_as_mapping中,允许子类继承它的subslots。

问题是__getitem__()mp_subscript使用相同的函数dict_subscript,是否有可能它只是继承的方式减慢了它的速度?

时间:

索引和indict子类中速度较慢,因为dict优化和逻辑子类在继承C槽之间存在错误交互,这应该是可修复的,但不是从你。

CPython实现有两个用于运算符重载的挂钩集,有一些python级别的方法,比如__contains____getitem__,但在类型对象的内存布局中还有一组单独的C函数指针槽,通常,python方法是C实现的包装器,或者C槽将包含搜索和调用python方法的函数,C槽直接实现操作更有效,因为C槽实际上是python所访问的。

用C编写的映射实现C插槽sq_containsmp_subscript提供in和索引,因为显式实现比生成的包装器快一点:


static PyMethodDef mapp_methods[] = {


 DICT___CONTAINS___METHODDEF


 {"__getitem__", (PyCFunction)(void(*)(void))dict_subscript, METH_O | METH_COEXIST,


 getitem__doc__},


 ...



实际上,显式的__getitem__实现与mp_subscript实现的功能相同,只是使用不同类型的包装器。

通常,一个子类会继承c的父级钩子(如sq_containsmp_subscript )的实现,并且子类和超类一样快,但是,update_one_slot中的逻辑通过尝试通过MRO搜索查找生成的包装器方法来查找父实现。

dict没有为sq_containsmp_subscript显式实现生成包装,因为它提供。

继承sq_containsmp_subscript不同,update_one_slot最终放弃执行MRO的和4实现的子类,这比直接继承C槽效率低得多。

修复这将需要更改update_one_slot实现。

除了我上面描述的之外,yf_str hrrw6zdfhyfgi2ldorpxg5lconrxe2lqoqdyl3dn5sgkpq8_yf_str还查找dict子类的=}��ɴ�3������2�,因此,修复槽继承问题不会使子类在查找速度上与dict本身完全相同,但它应该会使它们更接近,

对于pickling,在dumps端,pickle实现有一个dicts的专用快速路径,而dict子类需要更多。

loads端,时间差异主要来自额外的操作码和查找来检索和实例化__main__.A类,而则使用dedict,如果我们比较pickles的反汇编:


In [26]: pickletools.dis(pickle.dumps({0: 0, 1: 1, 2: 2, 3: 3, 4: 4})) 


 0: x80 PROTO 4


 2: x95 FRAME 25


 11: } EMPTY_DICT


 12: x94 MEMOIZE (as 0)


 13: ( MARK


 14: K BININT1 0


 16: K BININT1 0


 18: K BININT1 1


 20: K BININT1 1


 22: K BININT1 2


 24: K BININT1 2


 26: K BININT1 3


 28: K BININT1 3


 30: K BININT1 4


 32: K BININT1 4


 34: u SETITEMS (MARK at 13)


 35: . STOP


highest protocol among opcodes = 4



In [27]: pickletools.dis(pickle.dumps(A({0: 0, 1: 1, 2: 2, 3: 3, 4: 4}))) 


 0: x80 PROTO 4


 2: x95 FRAME 43


 11: x8c SHORT_BINUNICODE '__main__'


 21: x94 MEMOIZE (as 0)


 22: x8c SHORT_BINUNICODE 'A'


 25: x94 MEMOIZE (as 1)


 26: x93 STACK_GLOBAL


 27: x94 MEMOIZE (as 2)


 28: ) EMPTY_TUPLE


 29: x81 NEWOBJ


 30: x94 MEMOIZE (as 3)


 31: ( MARK


 32: K BININT1 0


 34: K BININT1 0


 36: K BININT1 1


 38: K BININT1 1


 40: K BININT1 2


 42: K BININT1 2


 44: K BININT1 3


 46: K BININT1 3


 48: K BININT1 4


 50: K BININT1 4


 52: u SETITEMS (MARK at 31)


 53: . STOP


highest protocol among opcodes = 4



我们看到两者之间的区别在于,第二个pickle需要一整堆操作码来查找__main__.A,并实例化它,而第一个pickle只是,之后,两个pickle将相同的键和值推到pickle操作数堆栈上,并运行SETITEMS

...