我当时正在研究一个扩展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
结果确实令人惊讶。关键查找速度慢2倍,而pickle
慢5倍。
怎么可能?其他方法,例如get()
,__eq__()
和__init__()
,以及在keys()
,values()
和items()
上的迭代,其速度与{{1} }。
编辑:我看了Python 3.9的源代码,在dict
中,看来Objects/dictobject.c
方法是由__getitem__()
实现的。并且dict_subscript()
仅在缺少键的情况下才减慢子类的速度,因为子类可以实现dict_subscript()
并尝试查看其是否存在。但是基准是使用现有密钥。
但是我注意到了一些:__missing__()
是用标志__getitem__()
定义的。另外METH_COEXIST
,另一种慢2倍的方法,具有相同的标志。来自official documentation:
将代替现有定义加载该方法。不带 METH_COEXIST,默认为跳过重复的定义。自开槽 包装器在方法表之前被加载,存在一个 例如,sq_contains插槽将生成一个包装方法,名为 包含(),并禁止加载具有相同名称的相应PyCFunction。定义好标志后,PyCFunction将为 代替包装对象加载,并将与插槽共存。 这很有用,因为对PyCFunctions的调用比 包装对象调用。
因此,如果我理解正确,理论上__contains__()
可以加快速度,但似乎起到相反的作用。为什么?
编辑2 :我发现了更多内容。
METH_COEXIST
和__getitem__()
被标记为__contains()__
,因为它们在PyDict_Type中两次声明了两次。
它们都一次出现在插槽METH_COEXIST
中,在其中它们分别被明确声明为tp_methods
和__getitem__()
。但是official documentation说__contains()__
不是由子类继承的。
因此tp_methods
的子类不调用dict
,而是调用子槽__getitem__()
。实际上,mp_subscript
包含在插槽mp_subscript
中,它允许子类继承其子插槽。
问题是tp_as_mapping
和__getitem__()
都使用相同函数mp_subscript
。可能仅仅是它的继承方式减慢了它的速度吗?
答案 0 :(得分:8)
in
子类中,索引和dict
的速度较慢,这是因为dict
优化与用于继承C插槽的逻辑子类之间的不良交互。这应该是可修复的,尽管不是从您的头开始的。
CPython实现具有两组用于运算符重载的钩子。有__contains__
和__getitem__
之类的Python级方法,但类型对象的内存布局中还有一组用于C函数指针的单独插槽。通常,Python方法将是C实现的包装,或者C插槽将包含搜索和调用Python方法的函数。由于C插槽是Python实际访问的,因此C插槽直接执行该操作效率更高。
用C编写的映射实现C插槽sq_contains
和mp_subscript
,以提供in
和索引。通常,Python级别的__contains__
和__getitem__
方法将作为C函数的包装器自动生成,但是dict
类的__contains__
的{{3}}和__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_contains
和mp_subscript
,并且子类将与超类一样快。但是,explicit implementations中的逻辑通过尝试通过MRO搜索找到生成的包装器方法来寻找父级实现。
dict
没有为sq_contains
和mp_subscript
生成包装器,因为它提供了明确的__contains__
和__getitem__
实现
sq_contains
而不是继承mp_subscript
和update_one_slot
,而是提供了子类sq_contains
和mp_subscript
实现,它们对{{1}执行MRO搜索}和__contains__
并调用它们。这比直接继承C插槽的效率低得多。
要解决此问题,将需要更改__getitem__
实现。
除了我上面描述的内容之外,update_one_slot
还会查找dict子类的dict_subscript
,因此解决插槽继承问题不会使子类与__missing__
本身完全一样速度,但可以使它们更接近。
关于腌制,在dict
端,pickle的实现有一个update_one_slot
的字典,而dict子类通过dumps
和object.__reduce_ex__
则采用了更round回的路径。
在save_reduce
端,时间差主要来自额外的操作码和查找,以检索和实例化loads
类,而字典具有专用的pickle操作码来制作新字典。如果我们比较泡菜的拆解:
__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
并将其实例化,而第一个泡菜只是执行__main__.A
以获取空字典。之后,两个泡菜都将相同的键和值压入泡菜操作数堆栈并运行EMPTY_DICT
。