nujson 诞生记

前言

这个夏天,我们尝试将一个 Python2.7 的项目升级到 Python3.7,期间发现 NumPy 在 Python3 上出现了一些和 JSON 序列化有关的问题,

解决问题的方法有跟多,如手工把 NumPy 的类型转换,用支持 default 参数 json 库并添加类型转换代码。但是这些转换都是在 Python 侧实现的,在一个需要频繁 JSON 序列化的项目中这样做会增加处理时间。不仅如此,在程序处理的内部流程中将 NumPy 类型的变量转换为 Python 原生的类型还会失去 NumPy 带来的高效性。而且在处理复杂结构的词典数据时,出现 NumPy 向量的地方很难断定。种种原因使得简单的 NumPy 的序列化问题需要在 JSON 库的层面做改变。


尝试

市面上支持序列化 NumPy 向量的 JSON 库有通过网络检索发现:

  1. orjson 可以通过 default。 功能支持 NumPy 类型转换的包
  2. Pandas 维护的 ujson 是个不错的选择

我们首先尝试了 orjson 这个库,参考了项目主页的性能对比,并用我们生产环境的数据做的测试:

JSON 库 dumps1 dumps2 loads
nujson 7.92 32.89 2.72
Pandas 10.57 32.46 2.97
orjson 18.88 10.70 6.19
重复次数 20000 20000 30000
代码 Gist Gist Gist
备注 数据来自生产环境 数据来自 ujson 性能测试数据 数据来自生产环境

结果分析:

  • 从序列化(dumps)角度看:
    1. 如果词典的内容非常标准,没有 NumPy 等用 C 实现的内容,那么序列化的性能是非常可观的,大幅度超过其他 JSON 库
    2. 如果词典内容含有一些 NumPy 的数据类型,则需要手工添加 default 函数,当函数要处理的类型数量达到 3 时,orjson 的性能就会比 Python2 时的 ujson 慢接近一倍。对于彩云天气这样高度依赖 NumPy 的场景来说,用 orjson 反而会得不偿失
  • 从反序列化(loads)角度看:
    • 使用生产环境的数据做了性能测试,发现 orjson 的反序列化速度不如 ujson,这也是其项目主页的性能测试所证实的

随后我们尝试了 Pandas 的 ujson,发现虽然对 NumPy 的支持比较好,但是 Pandas 包本身比较庞大,而且处理速度较之于 Python2 时代的 ujson 要慢。为了一个 JSON dumps 功能而使用 Pandas 此时显得有些得不偿失。所以我们决定为 ujson 打一个补丁,使之能满足我们自身需要。


自己动手

我们参考了 Pandas 的思路,在 C 语言端实现了对 NumPy 的类型判断与转换,最终在不影响系统性能的前提下,使项目的 JSON 序列化与反序列化处理步骤平滑过渡到可用状态。本节重点展开相关的技术的细节。

np.int64

最先被发现的问题是 int64 类型是无法被 JSON 序列化,进而导致 ujson 报错 Maximum recursion level reached,使用 Python 内置的 JSON 库才定位到 int64 类型存在问题。

对使用 NumPy 的地方做了进一步的排查发现 int64 类型的数据在程序运行过程中会持续很长的范围。综合考量之下,我们才开始着手打补丁。我的同事解决了对 NumPy int64 的支持,有关的代码变动在 Commit 187bd15 中可以找到,核心的代码片段如下所示:

  if (PyNumber_Check(obj))
   {
     PRINTMARK();
     #ifdef _LP64
     pc->PyTypeToJSON = PyIntToINT64; tc->type = JT_LONG;
     #else
     pc->PyTypeToJSON = PyIntToINT32; tc->type = JT_INT;
     #endif
     return;
     }
   else

待解决的 np.float32

后来刷 ujson 的 issue 时发现了 ujson 无法正常处理 float32 的问题,发现我们也存在这个问题,表现为 1.9 的浮点数被处理成了 1。

>>> import nujson
>>> import numpy as np
>>> x = np.float32(1.9)
>>> nujson.dumps(x)
1  

但是原始的 ujson v1.35 则根本无法处理 float32 类型:

>>> import ujson
>>> import numpy as np
>>> x = np.float32(1.9)
>>> ujson.dumps(x)
OverflowError: Maximum recursion level reached  

所以可以判断 float32 类型被我们增加的针对 int64 的补丁给处理了。那么问题就是如何区分这两个类型,

区分 int 与 flot32

首先我们看这样一段程序:

>>> import numbers
>>> import numpy as np
>>> isinstance(np.int64(1), numbers.Number)
True  
>>> isinstance(np.float32(1.2), numbers.Number)
True  

可以看到无论是 np.int64 还是 np.float32 都是 numbers.Number,所以正确处理这两种类型的方法应当在源代码中去找,cpython/Objects/abstract.c 的 758-765 行定义了一个 PyNumber_Check 函数,提供了 Python 对 number 的判断,其代码片段如下所示:

int  
PyNumber_Check(PyObject *o)  
{
    return o && o->ob_type->tp_as_number &&
           (o->ob_type->tp_as_number->nb_index ||
            o->ob_type->tp_as_number->nb_int ||
            o->ob_type->tp_as_number->nb_float);
}

所以我们「似乎」看到了区别 float 和 int 的方法,即通过 o->ob_type->tp_as_number->nb_into->ob_type->tp_as_number->nb_float 做区分。

然而在实际中,并不能有效区分开来,int64 和 float32 对这两个判断的结果都是 True, 实验的代码可以在这个页面的红色区域找到。

后来尝试了 PyArray_Check() && PyArray_ISFLOAT() 也不行,最终靠 if (PyArray_IsScalar(obj, Float) || PyArray_IsScalar(obj, Double)) 成功区分开来。

此时的结果只差最终的输出格式了:

>>> import nujson
>>> import numpy as np
>>> x = np.float32(1.9)
>>> nujson.dumps(x)
1.8999999762  

未完成的正确输出

Float32 类型数据的输出格式是一个长长的浮点数,故将目光再次转向了 Pandas,发现 Pandas 面对 float32 也无法正常:

>>> import pandas._libs.json as pdujson                                                                                                                                                               
>>> import numpy as np                                                                                                                                                                                
>>> pdujson.dumps({"x": np.float32(1.9)})                                                                                                                                                             
'{"x":1.8999999762}'  

我一时没有想清楚为什么,所以看了下 np.float32 的一些属性,发现在 Python 侧是可以得到形式良好的结果:

>>> import numpy as np
>>> x = np.float32(1.9)
>>> x.tolist()
1.899999976158142  
>>> x.item()
1.899999976158142  
>>> x.view()
1.9  
>>> str(x)
'1.9'  
>>> float(str(x))
1.9  

我也在 NumPy 的官方仓库上发了 issue 询问相关问题,得到了如下回复:

What you are seeing is perfectly expected. Floating point numbers are not exact in most cases and float32 has a lower precision than float64:

https://en.wikipedia.org/wiki/Single-precisionfloating-pointformat

1.9 is never an exact number, the number which is closest to 1.9 is printed as "1.9". But what is closest to 1.9 in single precision is not the closest anymore when you to double precision. You can round, or similar things, but generally... If you do not expect precision loss here, you may want to stick to 64bit floating point numbers, since 32bit floating point numbers have too little precision for many tasks.

所以这个问题本身是个「伪问题」,是 float32 数据类型本身导致的。

查阅 NumPy 的 API 文档时找到了 PyArray_View 这个方法,但是受限于时间与精力,暂时没有时间去调试代码了。欢迎找到解决思路的人发起 PR。


开源

Ujson 的原始维护组织 ESN 官网已经打不开,组织成员也很久不再活跃。Python2.7 也即将退休,未来可能会有更多人遇到这个问题,我们决定将修改后的 ujson 开源出来并提交到了 PyPI 上,希望有需要的人能找到它。

项目开源在了彩云科技的 GitHub 组织下 caiyunapp/ultrajson,可以通过 pip install nujson 直接安装,欢迎 star 与 PR,并将使用时的问题反馈给我们。

ringsaturn

继续阅读此作者的更多文章