如何更快地将string转换成int/long

​​​​​

  • 问题提出

  • Native 方案

  • Naive 方案

  • 循环展开方案

  • byteswap 方案

  • 分治方案

  • trick 方案

  • SIMD trick 方案

  • 总结

 

大家好,Kirito 今天又来分享性能优化的骚操作了。

在很多追求性能的程序挑战赛中,经常会遇到一个操作:将 String 转换成 Integer/Long。如果你没有开发过高并发的系统,或者没有参加过任何性能挑战赛,可能会有这样的疑问:这有啥好讲究的,Integer.valueOf/Long.valueOf 又不是不能用。实际上,很多内置的转换工具类只满足了功能性的需求,在高并发场景下,可能会是热点方法,成为系统性能的瓶颈。

但这并不意味着开发者就无法优化这个问题。事实上,有很多方法可以解决这个问题,例如使用正则表达式或手写方法进行转换。这些方法可能需要额外的代码实现,但在高并发环境下,它们可以显著提高系统的性能。

文章开头,我先做一下说明,本文的测试结论出自:Faster Integer Parsing 。测试代码基于 C++,我会在翻译原文的同时,添加了部分自己的理解,以协助读者更好地理解其中的细节。

问题提出

假设现在有一些文本信息,固定长度为 16 位 ,例如下文给出的时间戳,需要尽可能快地解析这些时间戳

 

方法体如下所示: 

问题提出后,大家不妨先思考下,如果是你,你会采取什么方案呢?带着这样的思考,我们进入下面的一个个方案。

Native 方案

我们有哪些现成的转换方案呢?

  • 继承自 C 的 std::atoll

  • std::stringstream

  • C++17 提供的 charconv

  • boost::spirit::qi

评测程序采用 Google Benchmark 进行对比评测。同时,我们以不做任何转换的方案来充当 baseline,以供对比。(baseline 方案在底层,相当于将数值放进来了寄存器中,所以命名成了 BM_mov)

下面给出的评测代码不是那么地关键,只是为了给大家展示评测是如何运行的。

 

可以发现 stringstream 表现的非常差。当然,这并不是一个公平的比较,但从测评结果来看,使用 stringstream 来实现数值转换相比 baseline 慢了 391 倍。相比之下, <charconv> 和 boost::spirit 表现的更好。

既然我们已经知道了目标字符串包含了要解析的数字,而且不需要做任何的数值校验,基于这些前提,我们可以思考下,还有更快的方案吗?

Naive 方案

我们可以通过一个再简单不过的循环方案,一个个地解析字符。

 

 

 

虽然这层 for 循环看起来呆呆的,但如果这样一个呆呆的解决方案能够击败标准库实现,何乐而不为呢?前提是,标准库的实现考虑了异常场景,做了一些校验,这种 for 循环写法的一个前提是,我们的输入一定是合理的。

之前我也提到过这个方案。显然, naive 的方案之后还会有更优的替代方案。

循环展开方案

记得我们在文章的开头加了一个限定,限定了字符串长度固定是 16 位,所以循环是可以被省略的,循环展开之后,方案可以更快。

 

byteswap 方案

先思考下,如果继续围绕上述的方案进行,我们可能只有两个方向:

  1. 并发执行加法和乘法计算,但这种 CPU 操作似乎又不能通过多线程之类的手段进行加速,该如何优化是个问题

  2. 将乘法和加法运算转换成位运算,获得更快的 CPU 执行速度,但如果转换又是个问题

相信读者们都会有这样的疑问,那我们继续带着这样疑问往下看原作者的优化思路是什么。

紧接着上述的循环展开方案,将 “1234” 解析为 32 位整数对应的循环展开操作绘制为图,过程如下:

 

 

我们可以看到,乘法和加法的操作次数跟字符的数量是线性相关的。由于每一次乘法都是由不同的乘数进行,所以我们不能只乘“一次”,在乘法的最后,我们还需要将所有结果相加。乍一看,好像很难优化。

下面的优化技巧,需要一些操作系统、编译原理相关的知识作为辅助,你需要了解 byteswap 这个系统调用,了解大端序和小端序的字节序表示方法(后面我也会分享相关的文章),如果你不关心这些细节,也可以直接跳到本段的最后,直接看结论。

理解清楚下图的含义,需要理解几个概念:

  • 字符 1 对应的 ascii 值是 31,相应的 2 对应 324 对应 34

  • 在小端序机器上(例如 x86),字符串是以大端序存储的,而 Integer 是以小端序存储的

  • byteswap 可以实现字节序调换

    上图展示了十六进制表示下的转换过程,可以在更少的操作下达到最终的解析状态。

    将上图的流程使用 C++ 来实现,将 String 重新解释为 Integer,必须使用 std::memcpy(避免命名冲突),执行相减操作,然后通过编译器内置的 __builtin_bswap64 在一条指令中交换字节。到目前为止,这是最快的一个优化。

    我们看上去得到了想要的结果,但是这个方案从时间复杂度来看,仍然是 O(n) 的,是否可以在这个方案的基础上,继续进行优化呢?

    分治方案

    从最初的 Native 方案,到上一节的 byteswap 方案,我们都只是优化了 CPU 操作,并没有优化复杂度,既然不满足于 O(n),那下一个复杂度可能性是什么?O(logn)!我们可以将每个相邻的数字组合成一对,然后将每对数字继续组合成一组四个,依此类推,直到我们得到整个整数。

    如何同时处理邻近的数字,这是让算法跑进 O(logn) 的关键

    该方案的关键之处在于:将偶数位的数字乘以 10 的幂,并且单独留下奇数位的数字。这可以通过位掩码(bitmasking)来实现

     

    通过 bitmasking,我们可以一次对多个数字进行操作,将它们组合成一个更大的组合

    通过使用这个掩码技巧来实现前文提到的 parse_8_chars 函数。使用 bitmasking 的另一好处在于,我们不用减去 '0' ,因为位掩码的副作用,使得我们正好可以省略这一步。

    trick 方案

    综合前面两节,解析 16 位的数字,我们将它分成两个 8 字节的块,运行刚刚编写的 parse_8_chars,并对其进行基准测试!

     

     

    看上去优化的不错,我们将循环展开方案的基准测试优化了近 56% 的性能。能做到这一点,主要得益于我们手动进行一系列 CPU 优化的操作,虽然这些并不是特别通用的技巧。这样算不算开了个不好的头呢?我们看起来对 CPU 操作干预地太多了,或许我们应该放弃这些优化,让 CPU 自由地飞翔。

    SIMD trick 方案

    你是不是以为上面已经是最终方案了呢?不,优化还剩最后一步。

    我们已经得到了一个结论

  • 同时组合多组数字以实现 O(logn) 复杂度

  • 如果有 16 个字符或 128 位的字符串要解析,还可以使用 SIMD。感兴趣的读者可以参考SIMD stands for Single Instruction Multiple Data。Intel 和 AMD CPU 都支持 SSE 和 AVX 指令,并且它们通常使用更宽的寄存器。

    SIMA 简单来说就是一组 CPU 的扩展指令,可以通过调用多组寄存器实现并行的乘法运算,从而提升系统性能。我们一般提到的向量化运算就是 SIMA。

    让我们先设置 16 个字节中的每一个数字:

     

    现在,主角变成了 madd 该系统调用。这些 SIMD 函数与我们使用位掩码技巧所做的操作完全一样——它们采用同一个宽寄存器,将其解释为一个由较小整数组成的向量,每个乘以一个特定的乘数,然后将相邻位的结果相加到一个更宽的整数向量中。所有操作一步完成。 

    2 字节方案其实还有另一条指令,但不幸的是我并没有找到 4 字节方案的指令,还是需要两条指令。这是完整的 parse_16_chars 方案: 

     

    SIMD trick

    0.75 nanoseconds ! 是不是大吃一惊呢.

  • 总结

  •  

    有人可能会问,你为啥要用 C++ 来介绍下,不能用 Java 吗?我再补充下,本文的测试结论,均来自于老外的文章,文章出处见开头,其次,本文的后半部分的优化,都是基于一些系统调用,和 CPU 指令的优化,这些在 C++ 中实现起来方便一些,Java 只能走系统调用。

    在最近过去的性能挑战赛中,由于限定了不能使用 JNI,使得选手们只能将方案止步于循环展开方案,试想一下,如果允许走系统调用,加上比赛中字符串也基本是固定的长度,完全可以采用 SIMD 的 trick 方案,String 转 Long 的速度会更快。

    实际上,在之前的polarDB比赛中,C++比Java更方便执行某些CPU指令集和系统调用。虽然这个优化过程令人兴奋,但我们也发现,随着优化的进行,越来越需要使用底层的优化技巧,例如在所提出的方案中使用的trick,其适用性是有限的。也有人说:花费这么大精力去优化,为什么不去写汇编呢?这又回到了“优化是万恶之源”的话题。在业务项目中,您可能不需要过多关注String如何转换为Long和Integer,因为Integer.valueOf和Long.valueOf可能已经足够满足您的需求。但如果您是一个需要处理大数据的解析系统,String转换可能是系统的瓶颈之一,相信本文提出的方案会给您带来一定的启示。

    此外,关于SIMD等方案,我想再多说几句。实际上,在一些性能挑战赛的最后阶段,各参赛者的整体方案其实都相差无几,无非是参数差异。因为比赛场景通常不会太复杂,所以前几名的差距通常在一些非常小的细节上。正如SIMA提供的向量化运算等优化技巧,它们可以帮助您比其他人快几百毫秒,甚至1-2秒。这时您会惊叹,原来我与大神之间的差距就在这些细节上。但是,回顾整个过程,这些优化似乎并不能帮助程序设计竞赛发挥更大的能量。如果一个比赛只能依靠CPU优化来实现区分度,我觉得那肯定不是成功的比赛。因此,对于主办方而言,禁用一些类库实际上有效地避免了内卷,而对于参赛者而言,则是减轻了负担。希望未来的比赛都朝着让参赛者花更多精力去优化方案而不是优化通用细节的方向发展。

    再回到字符串解析成Long/Integer的话题上。在实际使用中,您也不必避免继续使用Integer.valueOf或Long.valueOf。在大多数情况下,它们都不是系统的瓶颈。但如果您在某些场景下遇到了字符串转换的瓶颈,希望本文能对您有所帮助。如果您想继续优化这些方面,可以考虑以下几个方面:

    优化算法和数据结构,以减少字符串转换的数量。

    使用更高效的字符串转换库,例如fastutil。

    考虑使用golang等更适合处理字符串的语言。

     

     

     

     

     

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

永钊源码

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值