Ruby JSON 优化之路:性能提升的探索与实践

在 Ruby 编程的世界里,JSON 处理是一个至关重要的环节。今天,就来深入探讨一下 Ruby JSON 的优化过程,看看如何让它的性能更上一层楼。

一、批量 API 优化:解决哈希表构建效率问题

在之前的工作中,我们已经着手进行了一些解析器的优化。这次,让我们先来看看 twitter.json 的火焰图,找找还有哪些地方可以继续优化。

从火焰图中可以发现,rb_hash_aset 占用了高达 26.6% 的时间,这可是个不小的问题。要知道,像 simdjsonrapidJSON 等都是非常快速的 JSON 解析器,可能有人会问,为什么不直接绑定这些解析器来提升 ruby/json 的速度呢?其实,除了存在诸多技术和法律限制外,一个重要原因是在实际应用中,JSON 解析本身并不是主要的瓶颈。即使是 ruby/json 中相对简单的 Ragel 解析器,速度也还算可以,当然,它还有很大的提升空间,这一点我们后面再说。

真正耗时的部分其实是构建 Ruby 对象树。从火焰图中 rb_hash_aset 的时间占比,以及在大量使用数组的基准测试中 rb_ary_push 的表现都能看出来。这就意味着,一个定制的解析器如果能更好地利用 Ruby API,就有可能在整体性能上超越其他解析器。比如,我们在 Ruby 字符串内部进行转义处理,避免额外的拷贝,并缓存字符串键,就能提高效率。

那么,rb_hash_aset 为什么会这么慢呢?我们仔细观察火焰图。首先看到左边的 ar_force_convert_table 函数,图中很多函数都以 st_rb_st 开头,还有一些以 ar_ 开头。st_ 相关的函数指向的是 st.c,这是 Ruby 内部的哈希表实现。虽然不知道 st 具体代表什么,但可以确定的是,st.c 是经过多年优化的哈希表,鉴于 Ruby 对哈希表的高度依赖,它的性能提升对整体影响很大。而 ar_ 则与 Ruby 哈希的一种优化机制有关。哈希表在处理大数据集时性能很好,但对于非常小的数据集,它会占用较多内存,而且在这种情况下,线性搜索可能更快。所以 Ruby 哈希有一个内部限制 RHASH_AR_TABLE_MAX_SIZE,在 64 位平台上这个值是 8。也就是说,任何包含 8 个或更少条目的 Ruby 哈希实际上是一个伪装成哈希的数组。这就是 ar_table,一个简单的键值对数组,用作关联数组。虽然它的算法复杂度在技术上是 O(n),但由于 n 很小,通常比哈希操作更快。当向一个由 ar_table 支持的哈希中添加足够的项时,它会自动转换为 st_table,这就是 ar_force_convert_table 函数的作用。

我们可以用 ObjectSpace.dump 这个 API 来验证这一点。比如,创建一个包含 8 个键值对的哈希:

require "objspace"
puts ObjectSpace.dump(8.times.to_h { |i| [i, i] })

结果显示这个哈希占用了 160B 的对象槽,计算一下就会发现是合理的。但如果创建一个包含 9 个键值对的哈希,情况就大不一样了,占用的内存会大幅增加。

另一个在火焰图中需要关注的函数是 rebuild_table_if_necessary。当向哈希表添加元素,并且哈希表快满时,就需要增加它的大小。与数组不同,这不仅仅是调用 realloc 的问题,而是需要分配一个更大的表,然后重新插入所有的键值对,这意味着要重新对键进行哈希计算,成本很高。

在解析 JSON 对象时,我们遇到了问题。当遇到 { 开始解析一个 JSON 对象时,我们会用 rb_hash_new 分配一个 Ruby 哈希,然后每次解析完一个键值对,就调用 rb_hash_aset 将其添加到哈希中。如果一个 JSON 文档包含一个有 30 个键的对象,我们首先会分配一个容量为 8 对的 ar_table,然后将其重建为能容纳 16 个条目的 st_table,最后再重建为能容纳 32 个条目的哈希表。这意味着每个键要被哈希 3 次,还浪费了很多时间在 mallocfree 上。

相比之下,在解析像 msgpack 或 Ruby 的 Marshal 格式时,哈希的起始字节后面会跟着哈希的预期大小,这样就可以预先分配正确大小的哈希表,效率大大提高。但 JSON 没有这个信息,我们只能边解析边处理,直到解析完才知道哈希的大小。

既然直接在不知道哈希大小的情况下向其添加元素不是个好办法,那我们能不能等解析完再构建哈希呢?答案是肯定的。但这就需要在解析过程中把内容存储在其他地方,最合适的结构就是栈(也就是数组)。

下面是优化前后的代码对比。优化前的解析代码大概是这样的:

def parse_object
  hash = {}
  until object_done?
    key = parse_object_key
    value = parse_json
    hash[key] = value
  end
  hash
end

就是简单地解析键值对,然后添加到哈希中,直到解析完整个对象。

优化后的代码如下:

def parse_object(stack)
  previous_size = stack.size
  until object_done?
    stack << parse_object_key
    stack << parse_json
  end
  hash = stack.pop(stack.size - previous_size).to_h
  stack << hash
  hash
end

现在每个解析函数都接收一个数组作为解析栈,解析出的内容都压入栈中。parse_object 函数也不例外,它先记录栈的初始大小,然后解析键和值并压入栈。当找到对象的结尾时,从栈中弹出所有的键值对,立即创建一个合适大小的哈希,确保每个键只被哈希一次。

在 C 语言中的实现如下:

long count = json->stack->head - stack_head;

VALUE hash;
#ifdef HAVE_RB_HASH_NEW_CAPA
hash = rb_hash_new_capa(count >> 1);
#else
hash = rb_hash_new();
#endif
rb_hash_bulk_insert(count, rvalue_stack_peek(json->stack, count), hash);
*result = hash;
rvalue_stack_pop(json->stack, count);

通过这个改变,twitter.json 基准测试的速度提高了 22%,效果显著。

二、避免双重扫描:优化字符串转义处理

在对值栈进行优化后,json_string_unescape 成为了新的性能瓶颈,占总运行时间的 22%

之前我们通过乐观地假设大多数字符串不包含转义字符对其进行了优化,但问题仍然存在。Ragel 解析器在调用 JSON_parse_string 回调时会传递字符串的起始和结束指针,这意味着它已经扫描了一遍字符串。但紧接着我们又在回调函数中再次扫描字符串,这显然是多余的。

原来 JSON 字符串的语法是这样定义的:

%%{
    machine JSON_string;
    include JSON_common;

    write data;

    action parse_string {
        *result = json_string_unescape(json, json->memo + 1, p, json->parsing_name, json->parsing_name || json-> freeze, json->parsing_name && json->symbolize_names);
        if (NIL_P(*result)) {
            fhold;
            fbreak;
        } else {
            fexec p + 1;
        }
    }

    action exit { fhold; fbreak;
    }

    main := '"' ((^([\"\\] | 0..0x1f) | '\\'[\"\\/bfnrt] | '\\u'[0-9a-fA-F]{4} | '\\'^([\"\\/bfnrtu]|0..0x1f))* %parse_string) '"' @exit;
}%%

这里我们可以看到,我们向 Ragel 详细说明了 JSON 字符串中所有可能的转义序列,但其实我们只需要解析器找到字符串的结尾,或者告诉我们遇到了流的结尾或无效字符就可以了,不需要它验证 \u 后面是否跟着 4 个十六进制字符,这个验证可以在转义处理时进行。

所以我们对其进行了改进,定义了新的语法:

%%{
    machine JSON_string;
    include JSON_common;

    write data;

    action parse_complex_string {
        *result = json_string_unescape(json, json->memo + 1, p, json->parsing_name, json->parsing_name || json-> freeze, json->parsing_name && json->symbolize_names);
        fexec p + 1;
        fhold;
        fbreak;
    }

    action parse_simple_string {
        *result = json_string_fastpath(json, json->memo + 1, p, json->parsing_name, json->parasing_name || json-> freeze, json->parsing_name && json->symbolize_names);
        fexec p + 1;
        fhold;
        fbreak;
    }

    double_quote = '"';
    escape = '\\';
    control = 0..0x1f;
    simple = any - escape - double_quote - control;

    main := double_quote (
         (simple*)(
            (double_quote) @parse_simple_string |
            ((^([\"\\] | control) | escape[\"\\/bfnrt] | '\\u'[0-9a-fA-F]{4} | escape^([\"\\/bfnrtu]|0..0x1f))* double_quote) @parse_complex_string          )     );


}%%

思路是先寻找前面没有反斜杠的双引号,如果匹配就进入 parse_simple_string 快速路径,如果不匹配则回退到之前的模式进入 parse_complex_string

快速路径的函数如下:

static VALUE json_string_fastpath(JSON_Parser *json, char *string, char *stringEnd, bool is_name, bool intern, bool symbolize) {
    size_t bufferSize = stringEnd - string;

    if (is_name) {
        VALUE cached_key;
        if (RB_UNLIKELY(symbolize)) {
            cached_key = rsymbol_cache_fetch(&json->name_cache, string, bufferSize);
        } else {
            cached_key = rstring_cache_fetch(&json->name_cache, string, bufferSize);
        }

        if (RB_LIKELY(cached_key)) {
            return cached_key;
        }
    }

    return build_string(string, stringEnd, intern, symbolize);
}

虽然这次对 twitter.json 的性能提升没有之前那么大,只提高了 1.03 倍,但我们对 Ragel 解析器的理解更深入了,也知道了 ruby/json 的 Ragel 解析器还有很多可以改进的地方。

三、避免无用拷贝:整数解析优化

在经历了上一次的优化挫折后,我们把目光转向了整数解析。虽然没有专门针对整数的微基准测试,但可以用 benchmark_parsing "small nested array", JSON.dump([[1,2,3,4,5]]*10) 这个小数组测试来进行分析。

从之前的经验可知,rb_cstr2inum 这个 Ruby 提供的将 C 字符串转换为 Ruby 整数的 API 效率不高。一方面,它需要一个 C 字符串,这就迫使我们先将字符串拷贝到一个缓冲区并添加 NULL 结尾;另一方面,它要处理很多我们不关心的情况,比如可变基数。例如 0xffrb_cstr2inum 来说是合法的数字,但在 JSON 中不是。而且它还必须支持任意长整数,这也会降低速度,而实际上我们解析的绝大多数数字都能在 64 位内表示。

所以我们可以创建一个快速路径函数来处理整数解析的核心部分,对于少数复杂情况再继续使用 rb_cstr2inum

实现如下:

static inline VALUE fast_parse_integer(char *p, char *pe) {
    bool negative = false;
    if (*p == '-') {
        negative = true;
        p++;
    }

    long long memo = 0;
    while (p < pe) {
        memo *= 10;
        memo += *p - '0';
        p++;
    }

    if (negative) {
        memo = -memo;
    }
    return LL2NUM(memo);
}

首先判断数字是否为负,然后逐个将 ASCII 字符转换为对应的整数。但这个方法只能处理能在原生整数类型内表示的整数,所以只有当数字的位数足够低时才会进入这个快速路径:

#define MAX_FAST_INTEGER_SIZE 18
long len = p - json->memo;
if (RB_LIKELY(len < MAX_FAST_INTEGER_SIZE)) {
    *result = fast_parse_integer(json->memo, p);
} else {
    fbuffer_clear(&json->fbuffer);
    fbuffer_append(&json->fbuffer, json->memo, len);
    fbuffer_append_char(&json->fbuffer, '\0');
    *result = rb_cstr2inum(FBUFFER_PTR(&json->fbuffer), 10);
}

这里选择 18 是因为在 C 中,long long 必须支持的最大值是 9,223,372,036,854,775,807,最小值是 −9,223,372,036,854,775,808,也就是 64 位整数,这是 19 位数字,但有些 19 位数字不能用 long long 表示,所以选择 18。当然,也可以用 unsigned long long 来处理稍大一点的数字,但作者认为不值得。

在微基准测试中,效果很好,速度提高了 1.54 倍。在更实际的测试中,对 twitter.json 提高了 1.07 倍,对 citm_catalog.json 提高了 1.11 倍。

四、避免重复工作:数字解析改进

json 2.9.0 中最后一个解析器优化是由 Aaron Patterson 提交的。他发现之前的解析器会先尝试解析浮点数,如果失败再尝试解析整数,这非常浪费资源。因为所有的浮点数都以整数开头,所以当要解析的下一个值是整数时,会先被 JSON_parse_float 完全扫描一遍,发现不是浮点数后,解析器又会回溯并在 JSON_parse_integer 中再次扫描相同的字节。

他将代码从:

np = JSON_parse_float(json, fpc, pe, result);
if (np!= NULL) {
    fexec np;
}
np = JSON_parse_integer(json, fpc, pe, result);

改为:

np = JSON_parse_number(json, fpc, pe, result);

同时对语法和状态机也做了一些修改,使这个改变能够实现。这个优化对 twitter.jsoncitm_catalog.json 都有不错的 5% 的速度提升。

至此,在发布 json 2.9.0 之前的所有优化工作就完成了。与其他解析器相比,现在的性能有了很大的提升。但这并不意味着优化就此结束,作者还有很多未来的想法,比如想放弃 Ragel 解析器,用一个更简单的递归下降解析器来替代它,因为作者觉得手动编写解析器比使用生成的解析器更容易操作。同时,作者还在和 Étienne Barrié 合作,致力于开发一个更好的解析器和编码器 API,进一步降低设置成本,减少对全局状态的依赖,使其更符合人体工程学。

希望大家通过这篇文章对 Ruby JSON 的优化过程有了更深入的了解。如果你在 Ruby 编程中也遇到过 JSON 处理的性能问题,或者有自己的优化经验,欢迎在评论区分享交流。

科技脉搏,每日跳动。

与敖行客 Allthinker一起,创造属于开发者的多彩世界。

- 智慧链接 思想协作 -

相关推荐
jackispy31 分钟前
JS宏进阶: 工厂函数与构造函数
java·开发语言·javascript
Dyan_csdn1 小时前
【JAVA项目】基于ssm的【游戏美术外包管理信息系统】
java·开发语言·游戏美术
忆源1 小时前
C -- 结构体内存对齐的原理
c语言·开发语言·算法
朔北之忘 Clancy1 小时前
2024 年 3 月青少年软编等考 C 语言二级真题解析
c语言·开发语言·c++·学习·算法·青少年编程·题解
xiaguangbo1 小时前
rust toml
开发语言·后端·rust
Panda-gallery1 小时前
【Rust】结构体定义域实例化
开发语言·后端·rust
℡52Hz★1 小时前
Three.js+Vue3+Vite应用lil-GUI调试开发3D效果(三)
开发语言·前端·javascript·3d
躺不平的理查德2 小时前
C 语言中二维数组的退化
c语言·开发语言·数据结构·算法
王子良.2 小时前
用 Python 自动化处理日常任务
开发语言·python·自动化
四念处茫茫2 小时前
【C语言系列】函数递归
c语言·开发语言·编辑器