了解cattrs的结构化代码是如何演变的

255 阅读7分钟

多年来,我在使cattrs快速化方面投入了大量的精力。

让我们看看这段时间cattrs的结构化代码是如何演变的。

cattrs是一个转换数据的库,它的主要用途之一是将attrs类转换为字典或从字典转换。(这些字典然后被送入一个序列化库,像 json、ujson 或 msgpack)。

为了本文的目的,想象一个这样的类。

from attrs import define

@define
class Test:
    a: int
    b: int
    c: float
    d: float
    e: str
    f: str

现在想象一下,在从HTTP请求中读取了一个JSON有效载荷的字典后,你希望将这个JSON有效载荷转化为这个类的一个实例。

v1: 早期的日子

cattrs 的早期,比如说大约 2019 年(这既是一生中的事,也是昨天的事),如果你要求cattrs将你的有效载荷结构化为这个类的实例,cattrs将执行与此(非常简化)代码大致相似的东西。

from attrs import fields

def structure_attrs_fromdict(payload, cls):
    res = {}
    for field in fields(cls):
        attribute_converter = get_converter(field.type)
        res[field.name] = attribute_converter(payload[field.name])
    
    return cls(**res)

尽管这个代码路径在今天被认为是次优的,但它仍然存在cattrs 的(现在,遗留的,即将被重新命名的)Converter 类中。

这段代码做得还不错,但问题是它的速度有点慢。每个字段的转换器(在这个例子中,两个整数、两个浮点数和两个字符串)需要在你每次调用这个函数时都被查找,尽管它们在转换器的生命周期内几乎肯定不会改变。还有for 循环的问题,每次都要创建一个迭代器,并对其进行迭代。

它的另一个问题是,任何额外的功能都会进一步减慢它的速度。也许你想重命名一个字段--需要进行更多的处理。也许你想完全省略一个字段--每个属性都要有额外的if语句。我想一定有一个更好的方法。

顺便提一下,在我的MacBook Pro和CPython 3.9上,将这个结构函数应用于一个适当的字典需要大约4.9微秒。这还不算太坏,但要记住这只是一个非常简单的例子。在现实生活中,类有更多的字段,类往往是嵌套的,而且字段类型和转换器也更复杂。

v2:GenConverter

在那些日子里,我有这样的想法(现在回想起来,非常愚蠢):如果cattrs比他们自己写的东西慢,人们就不会使用它。我问自己,给定一个Test 的实例,人类将如何编写结构化函数。然后我想起了这是 Python,在 Python 中代码可以写代码。如果你可以为你的类写一个优化的结构化函数,cattrs 也可以。于是GenConverter(生成转换器)诞生了。

GenConverter 第一次看到Test 时,它&aposll 为它生成一个自定义的结构化函数。通过使用 Python&apossinspect 模块,我们可以自己看一下实际生成的源。

>>> from inspect import getsourcelines
>>> from cattrs import GenConverter

>>> c = GenConverter()
>>> f = c._structure_func.dispatch(Test)

>>> for l in getsourcelines(f)[0]: print(l)

def structure_Test(o, *_):
  res = {
    &aposa&apos: structure_a(o[&aposa&apos], type_a),
    &aposb&apos: structure_b(o[&aposb&apos], type_b),
    &aposc&apos: structure_c(o[&aposc&apos], type_c),
    &aposd&apos: structure_d(o[&aposd&apos], type_d),
    &apose&apos: structure_e(o[&apose&apos], type_e),
    &aposf&apos: structure_f(o[&aposf&apos], type_f),
    }
  return __cl(**res)

正如你所看到的,这很像你&aposd 自己写的东西。当cattrs生成这个函数时,它提供了一个全局变量的字典--这就是structure_a 等的来历。

这种方法的主要好处是它分两个阶段进行工作--生成阶段和运行阶段。生成阶段花费的时间明显更长,但它只发生一次。生成阶段输出structure_Test ,然后缓存并在每次结构调用时运行该函数。

这也意味着我们可以在生成阶段做更多的工作,这使得整个类的功能基本上没有成本。重命名字段和处理通用类就属于这一类,同时解决个别字段的转换。

(注意,如果你所做的应用程序不能支付编译功能的前期费用,你仍然可以使用旧的Converter 代码路径来代替。例如,如果你正在编写一个CLI应用程序,快速启动是至关重要的)。

自2021年5月发布的cattrs1.7以来,这个代码路径一直是默认的。在我的Mac上,它需要~2.3微秒的时间来运行。这比旧方法的速度快了一倍多。

有一段时间,我想象着这是Python代码所能达到的最优化。几天前,在阅读Python-Dev邮件列表中的一个主题时,我发现我错了。

v3:通过更好的字节码实现更好的生活

cattrs将Python代码生成为简单的文本源代码行。然后,Python 解释器使用compileeval 内建程序摄取这些文本,并产生一个我们可以调用的函数对象。

不过还有一层,位于中间,不那么明显:函数字节码。这个字节码是Python解释器在调用一个函数时执行的低级指令的列表。让我们看看我们使用Python&apossdis 模块生成的structure_Test 的字节码。

>>> from dis import dis
>>> dis(f)
  3           0 LOAD_GLOBAL              0 (structure_a)
              2 LOAD_FAST                0 (o)
              4 LOAD_CONST               1 (&aposa&apos)
              6 BINARY_SUBSCR
              8 LOAD_GLOBAL              1 (type_a)
             10 CALL_FUNCTION            2

  4          12 LOAD_GLOBAL              2 (structure_b)
             14 LOAD_FAST                0 (o)
             16 LOAD_CONST               2 (&aposb&apos)
             18 BINARY_SUBSCR
             20 LOAD_GLOBAL              3 (type_b)
             22 CALL_FUNCTION            2
             
         ... similar lines omitted...
         
  2          72 LOAD_CONST               7 ((&aposa&apos, &aposb&apos, &aposc&apos, &aposd&apos, &apose&apos, &aposf&apos))
             74 BUILD_CONST_KEY_MAP      6
             76 STORE_FAST               2 (res)

 10          78 LOAD_GLOBAL             12 (__cl)
             80 BUILD_TUPLE              0
             82 BUILD_MAP                0
             84 LOAD_FAST                2 (res)
             86 DICT_MERGE               1
             88 CALL_FUNCTION_EX         1
             90 RETURN_VALUE

这个函数大量使用了LOAD_GLOBAL --我们通过函数的globals提供了大量的数据,所以不足为奇。事实也证明,Python函数基本上可以访问两个作用域--全局作用域和局部作用域--而从全局作用域加载对象要比从局部作用域加载对象慢得多!

当一个对象从局部范围加载时,你会看到LOAD_FAST 指令而不是LOAD_GLOBAL 指令。局部作用域主要用于定义在函数中的变量,因此而得名。不过,如果我们能够生成我们的函数,从本地命名空间读取我们为它准备的所有对象,那不是很好吗?

我们可以使用一个技巧。我们可以通过将这些对象设置为假参数的缺省值来将它们全部纳入本地范围。结构函数的预期接口是简单的,所以如果我们在函数签名中粘上一堆只有关键字的默认参数,没有人会知道或介意。

所以cattrs 的下一个版本,22.1.0 (哦,对了,我们正在切换到 CalVer,就像attrs 一样),将做到这点。

首先,让我们看一下新的 Python 源码。

>>> for l in getsourcelines(f)[0]: print(l)
def structure_Test(o, _, *, __cl=__cl, __c_structure_a=__c_structure_a, __c_structure_b=__c_structure_b, __c_structure_c=__c_structure_c, __c_structure_d=__c_structure_d, __c_structure_e=__c_structure_e, __c_structure_f=__c_structure_f):
  return __cl(
    __c_structure_a(o[&aposa&apos]),
    __c_structure_b(o[&aposb&apos]),
    __c_structure_c(o[&aposc&apos]),
    __c_structure_d(o[&aposd&apos]),
    __c_structure_e(o[&apose&apos]),
    __c_structure_f(o[&aposf&apos]),
  )

正如我所提到的,我们在函数签名中加入了大量的变量,所以它现在超级混乱。我们可以在以后应用一些重复数据,但这并不重要。

我还对它进行了一些优化:如果没有必要,我们就不生成一个临时字典来保存各个字段,在这种情况下,我们也没有必要。

现在,如果我们看一下字节码。

>>> dis(f)
  2           0 LOAD_FAST                2 (__cl)

  3           2 LOAD_FAST                3 (__c_structure_a)
              4 LOAD_FAST                0 (o)
              6 LOAD_CONST               1 (&aposa&apos)
              8 BINARY_SUBSCR
             10 CALL_FUNCTION            1

  4          12 LOAD_FAST                4 (__c_structure_b)
             14 LOAD_FAST                0 (o)
             16 LOAD_CONST               2 (&aposb&apos)
             18 BINARY_SUBSCR
             20 CALL_FUNCTION            1
             
         ... similar lines omitted ...
             
  8          52 LOAD_FAST                8 (__c_structure_f)
             54 LOAD_FAST                0 (o)
             56 LOAD_CONST               6 (&aposf&apos)
             58 BINARY_SUBSCR
             60 CALL_FUNCTION            1

  2          62 CALL_FUNCTION            6
             64 RETURN_VALUE

甜蜜的,甜蜜的LOAD_FAST ,而且没有看到LOAD_GLOBAL 。但我们的劳动成果是什么呢?

在我的Mac上,这个版本需要1.39微秒的时间来运行。这是V2版解决方案的60%,是V1版方法的28%。如果我自己这么说的话,这还不算太坏。

CPython 3.11a4和PyPy

我一直使用CPython 3.9来运行这些基准测试,因为这是我目前工作中使用的版本。出于好奇,我在CPython 3.11的最新alpha版本(a4)和PyPy的最新稳定版本(7.3.7)上运行了这些基准测试,看看它们与我的老工作伙伴3.9相比如何。

CPython 3.11a4

  • v1: 3.57 us +- 0.14 us
  • v2:1.70 US +- 0.04 US
  • v3: 1.09 us +- 0.02 us

看起来3.11比3.9的v3方法快了~27%。好消息!

PyPy

  • v1: 1.68 us +- 0.05 us
  • v2:479 ns +- 13 ns
  • v3: 228 ns +- 6 ns

PyPy仍然是缩减CPU约束代码的王者。用228纳秒的时间来构造一个6个字段的类,这应该是接近本地的,对吗?