为什么在Python中进行子类化会使事情变慢呢?


13

我是工作在扩展的简单类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速度较慢,pickle5倍速度较慢。

怎么会这样?其他的方法,如get()__eq__()__init__(),和遍历keys()values()并且items()是一样快dict


编辑:我看了Python 3.9的源代码,Objects/dictobject.c似乎该__getitem__()方法是由实现的dict_subscript()。并且dict_subscript()仅在缺少键的情况下才放慢子类的速度,因为子类可以实现__missing__()并尝试查看它是否存在。但是基准是使用现有密钥。

但是我注意到了:__getitem__()用flag定义METH_COEXIST。并且__contains__(),另一种慢2倍的方法具有相同的标志。从官方文档中

该方法将代替现有定义加载。如果没有METH_COEXIST,则默认为跳过重复的定义。由于插槽包装程序是在方法表之前加载的,因此,例如sq_contains插槽的存在会生成一个名为contains()的包装方法, 并避免加载具有相同名称的相应PyCFunction。定义了标志后,PyCFunction将代替包装对象被加载,并与插槽共存。这很有用,因为对PyCFunctions的调用比对包装对象的调用进行了优化。

因此,如果我理解正确的话,理论上METH_COEXIST应该加快速度,但这似乎有相反的效果。为什么?


编辑2:我发现了更多。

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

它们都一次出现在插槽中tp_methods,并在其中显式声明为__getitem__()__contains()__。但是,官方文件说,tp_methods不是由子类继承。

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

问题是,无论是__getitem__()mp_subscript使用相同的功能,dict_subscript。可能仅仅是它的继承方式减慢了它的速度吗?


5
我无法找到源代码的特定部分,但是我相信C实现中有一条快速路径可以检查对象是否为a dict,如果是,则直接调用C实现,而不是__getitem__从中查找方法。对象的类。因此,您的代码执行两次dict查询,第一个对'__getitem__'class A成员字典中的键进行查找,因此可以预期它的速度大约是它的两倍。该pickle解释可能是非常相似的。
kaya3

@ kaya3:但是,如果这样,为什么len()不慢2倍,但速度却一样?
Marco Sulla

对此我不确定; 我以为len应该为内置序列类型提供一条快速路径。我认为我无法为您的问题提供适当的答案,但这是一个很好的答案,因此希望有人比我更了解Python内部知识。
kaya3

我已经进行了一些调查并更新了问题。
Marco Sulla

1
...哦。我现在看到了。显式__contains__实现阻止了用于继承的逻辑sq_contains
user2357112支持莫妮卡

Answers:


7

由于优化和用于继承C插槽的逻辑子类之间的不良交互,因此索引和子类中的索引in较慢。这应该是可修复的,尽管不是从您的角度出发。dictdict

CPython实现具有两组用于运算符重载的钩子。有Python级别的方法,例如__contains____getitem__,但是在类型对象的内存布局中还有一组用于C函数指针的单独插槽。通常,Python方法将成为C实现的包装,或者C插槽将包含一个搜索并调用Python方法的函数。由于C插槽是Python实际访问的位置,因此C插槽直接实现该操作效率更高。

用C编写的映射实现C插槽sq_containsmp_subscript提供in索引。通常情况下,Python的层次__contains____getitem__方法将自动为周围的C函数包装产生,但dict类有明确的实现__contains____getitem__,因为明确的实现比所产生的包装有点快:

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,因为它提供了明确__contains____getitem__实现。

相反,继承的sq_containsmp_subscriptupdate_one_slot结束了让子类sq_containsmp_subscript执行的MRO搜索实现__contains__,并__getitem__和调用这些。这比直接继承C插槽效率低得多。

解决此问题将需要对update_one_slot实现进行更改。


除了我上面描述的内容之外,dict_subscript还查找__missing__dict子类,因此解决插槽继承问题不会使子类在dict查找速度上完全与自身相提并论,但应该使它们更加接近。


至于腌制,从dumps侧面看,腌制实现具有专用于dict的快速路径,而dict子类则通过object.__reduce_ex__和采取了更为round回的路径save_reduce

loads一边,时间差大多只是从额外的操作码和查询检索和实例化__main__.A类,而类型的字典有用于制作新的字典专用泡菜操作码。如果比较泡菜的拆解:

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确实EMPTY_DICT需要获取一个空字典。之后,两个泡菜将相同的键和值推入泡菜操作数堆栈并运行SETITEMS


非常感谢!您知道为什么CPython使用这种奇怪的继承方法吗?我的意思是,有没有申报的方式__contains__(),并__getitem()在可由子类继承这样的方式?在的官方文档中tp_methods,将其写为methods are inherited through a different mechanism,因此似乎有可能。
Marco Sulla

@MarcoSulla:__contains____getitem__ 继承的,但问题是,sq_containsmp_subscript没有。
user2357112支持Monica

恩,嗯...等等。我以为是相反的。__contains__并且__getitem__位于插槽中tp_methods,表示官方文档不被子类继承。正如您所说,update_one_slot不使用sq_containsmp_subscript
Marco Sulla

用不好的话说,contains剩下的不能简单地移到子类继承的另一个插槽中吗?
Marco Sulla

@MarcoSulla:tp_methods不会继承,但是从它生成的Python方法对象是继承的,因为标准MRO搜索属性访问将找到它们。
user2357112支持Monica
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.