Mar*_*lla 11 python performance dictionary subclass python-3.x
我是工作在扩展的简单类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
Run Code Online (Sandbox Code Playgroud)
结果真的出人意料。虽然键查找为2x速度较慢,pickle是5倍速度较慢。
怎么会这样?其他方法,如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 将代替包装器对象加载,并将与插槽共存。这很有用,因为对 PyCFunction 的调用比包装器对象调用更优化。
所以如果我理解正确的话,理论上METH_COEXIST应该会加快速度,但它似乎会产生相反的效果。为什么?
编辑 2:我发现了更多的东西。
__getitem__()并且__contains()__被标记为METH_COEXIST,因为它们在 PyDict_Type 中声明了两次。
它们都一次出现在 slot 中tp_methods,在那里它们被显式声明为__getitem__()and __contains()__。但是,官方文件说,tp_methods是不是由子类继承。
所以子类dict不调用__getitem__(),而是调用子槽mp_subscript。实际上,mp_subscript包含在 slot 中tp_as_mapping,允许子类继承其子槽。
问题是,无论是__getitem__()与mp_subscript使用相同的功能,dict_subscript。是否有可能只是它被继承的方式减慢了它的速度?
use*_*ica 11
由于优化和用于继承 C 插槽的逻辑之间的不良交互,索引和子类中的索引in速度较慢。这应该是可以解决的,尽管不是从你的角度来看。dictdict
CPython 实现有两组用于运算符重载的钩子。有 Python 级别的方法,例如__contains__和__getitem__,但在类型对象的内存布局中还有一组单独的 C 函数指针插槽。通常,Python 方法要么是 C 实现的包装器,要么 C 槽将包含一个搜索和调用 Python 方法的函数。C槽直接实现操作效率更高,因为C槽是Python实际访问的。
用 C 编写的映射实现 C 插槽sq_contains并mp_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__},
...
Run Code Online (Sandbox Code Playgroud)
(实际上,显式__getitem__实现与mp_subscript实现功能相同,只是使用了不同类型的包装器。)
通常,子类会继承其父类的 C 级钩子实现,如sq_containsand mp_subscript,并且子类将与超类一样快。但是,update_one_slot通过 MRO 搜索尝试查找生成的包装器方法来查找父实现的逻辑。
dict不具有对生成的包装sq_contains和mp_subscript,因为它提供了明确__contains__和__getitem__实现。
相反,继承的sq_contains和mp_subscript,update_one_slot结束了让子类sq_contains和mp_subscript执行的MRO搜索实现__contains__,并__getitem__和调用这些。这比直接继承 C 插槽效率低得多。
解决这个问题需要对update_one_slot实现进行更改。
除了我上面描述的内容之外,dict_subscript还要查找__missing__dict 子类,因此修复插槽继承问题不会使子类在dict查找速度方面与自身完全相同,但它应该使它们更接近。
至于酸洗,dumps另一方面,pickle 实现有一个专门的 dict快速路径,而 dict 子类则通过object.__reduce_ex__和采取更迂回的路径save_reduce。
另一方面loads,时间差主要来自于检索和实例化__main__.A类的额外操作码和查找,而 dicts 有一个专用的 pickle 操作码来创建新的 dict。如果我们比较泡菜的拆卸:
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
Run Code Online (Sandbox Code Playgroud)
我们看到两者之间的区别在于第二个pickle需要一大堆操作码来查找__main__.A和实例化它,而第一个pickle只是EMPTY_DICT为了得到一个空的dict。之后,两个pickle 将相同的键和值推送到pickle 操作数堆栈并运行SETITEMS。
| 归档时间: |
|
| 查看次数: |
1271 次 |
| 最近记录: |