沉寂了6年后带着一个帖子回来了。如果您必须尽可能快地解析微秒级的纪元时间戳,您会怎么做呢?我们将了解如何使用编译器内部函数在log(N)时间内完成此操作。
假设从理论上讲,您有一些基于文本的协议,或包含微秒时间戳的文件。您需要尽可能快地解析这些时间戳。可能是json,可能是CSV文件,也可能是其他定制的文件。它有16个字符,这也适用于信用卡号码。
让我们从可用的开始,并进行比较。我们有std::atoll函数,这个函数继承自C、std::stringstream、较新的C++17<;charconv>;头文件和请求Boost::SPIRIT::QI。我将使用Google Benchmark来测试性能,并有一个基准,让我们将最终结果加载到寄存器中进行比较-即不涉及实际解析。
让我们运行基准测试吧!代码在这里并不重要,它只显示正在进行基准测试的内容。
静态空BM_MOV(Benchmark::State&;state){for(AUTO_:State){Benchmark::DoNotOptimize(1585201087123789);}}Static void BM_ATOLL(Benchmark::State&;state){For(AUTO_:State){Benchmark::DoNotOptimize(std::atoll(Example_Timestamp));}}静态空BM_SSTREAM(Benchmark::State&;state){std::StringStream(Example_Time Stamp)。FOR(AUTO_:STATE){s.。参见kg(0);std::uint64_t i=0;s>;>;i;Benchmark::DoNotOptimize(I);}}静态无效bm_charconv(Benchmark::State&;state){auto s=Example_Timestamp;for(auto_:state){std::uint64_t result=0;std::from_char.。数据(),s。数据()+s。size(),result);Benchmark::DoNotOptimize(Result);}}静态无效BM_BOOST_SPIRIT(Benchmark::State&;state){Using Boost::SPIRE::QI::Parse;for(auto_:state){std::uint64_t result=0;parse.。数据(),s。数据()+s。size(),result);Benchmark::DoNotOptimize(Result);}}。
哇,串流真的很糟糕。这不是一个公平的比较,但是使用字符串流解析单个整数比仅将整数加载到寄存器慢391倍。通过比较,<;charconv>;和Boost::SPIRIT的表现要好得多。
既然我们知道我们的字符串包含我们试图解析的数字,并且我们不需要跳过任何空格,我们还能更快吗?验证到底花了多少时间?
让我们编写一个很好的旧for循环。逐个字符阅读字符串,并建立结果。
inline std::uint64_t parse_naive(std::string_view s)noExcept{std::uint64_t result=0;for(char digit:s){result*=10;result+=digit-';0';}返回结果;}。
对于一个简单的for循环来说,这实际上还不错。如果这样一个简单的解决方案能够击败标准库实现,那就意味着有相当多的精力投入到输入验证中。顺便提一下--如果您知道您的输入,或者可以进行更简单的验证,您可以获得一些显著的加速。
为了获得更多的解决方案和基准,让我们忽略标准库函数。我们应该可以开得比这快得多。
如果我们知道它是16个字节,为什么还要有一个forloop呢?让我们把它打开吧!
内联std::uint64_t parse_unroll(std::string_view s)无异常{std::uint64_t result=0;result+=(s[0]-';0';)*10000000000000ULL;result+=(s[1]-';0';)*100000000000000ULL;result+=(s[2]-';0';)。RESULT+=(s[3]-';0';)*1000000000000ULL;Result+=(s[4]-';0';)*100000000000ULL;Result+=(s[5]-';0';)*10000000000ULL;Result+=(s[6]-';0';)*1000000000ULL;Result+=(s[7]-&。0';)*100000000ULL;Result+=(s[8]-';0';)*10000000ULL;Result+=(s[9]-';0';)*1000000ULL;Result+=(s[10]-';0';)*100000ULL;Result+=(s[11]-';0';)*10000ULL;Result+=(s[12]-';0';)*1000ULL;Result+=(s[13]-';0';)*100ULL;Result+=(s[14]-';0';)*10ULL;Result+=(s[15]-';0';);返回结果;}。
好的,这又稍微好了一点,但我们仍在一次处理一个字符。
让我们在一个将‘1234’解析为32位整数的简化示例中,将展开的解决方案中的操作绘制为树:
我们可以看到,乘法和加法的数量与字符数呈线性关系。很难看出如何改进这一点,因为每个乘法都是由不同的因子进行的(所以我们不能“一口气”相乘),而且在一天结束时,我们需要将所有中间结果相加。
不过,还是很规律的。首先,字符串中的第一个字符乘以最大的因子,因为它是最有效的数字。
在小端机器(如x86)上,整数的第一个字节包含最低有效位,而字符串的第一个字节包含最高有效位。
现在,要将字符串的字节重新解释为整数,我们必须使用std::memcpy(以避免严格别名冲突),并且我们使用编译器instrendent__builtin_bswap64在一条指令中交换字节。memcpy将得到优化,所以到目前为止这是一个胜利。
模板<;TypeName T>;内联T GET_ZEROS_STRING()NOEXCEPT;TEMPLATE<;&gT;内联STD::uint64_t GET_ZEROS_STRING<;STD::uint64_t>;()NOEXCEPT{std::uint64_t Result=0;constexpr char zeros[]=";00000000";;std::memcpy(&;result。}inline std::uint64_t parse_8_chars(const char*string)noexception{std::uint64_t chunk=0;std::memcpy(&;chunk,string,sizeof(Chunk));chunk=_builtin_bswap64(chunk-get_zeros_string<;std::uint64_t>;());//.}。
但现在我们有了一个看起来像我们想要的整数,我们如何才能在不做太多工作的情况下让它越过终点线呢?
从上一步开始,我们得到一个整数,它的位表示将每个数字放在单独的字节中。也就是说,即使一个字节最多可以表示256个值,我们在整数的每个字节中都有值0-9,它们也是按照正确的小端顺序排列的。现在我们只需要以某种方式将它们“打碎”在一起。
我们知道线性地做会太慢,下一种可能性是什么?o(log(N))!我们需要在一个步骤中将每个相邻的数字组合成一对,然后将每对数字组合成一组四个数字,依此类推,直到我们拥有整个整数。
在我发布了这篇文章的第一个版本后,Sopel97onreddit指出byteswap是没有必要的。以相反的方式组合相邻的数字-它们的顺序无关紧要。我意识到它对我的下一步洞察力有帮助,但在最终代码中可以省略。
密钥同时作用于相邻的数字。这允许操作树在O(log(N))时间内运行。
这包括将偶数位数乘以10的幂,而不考虑奇数位数。这可以使用位掩码来选择性地应用操作。
让我们通过使用这个掩码技巧来完成前面启动的parse_8chars函数。作为遮罩的一个很好的副作用,我们不需要减去它,因为它会被遮盖掉。
inline std::uint64_t parse_8_chars(const char*string)noException{std::uint64_t chunk=0;std::memcpy(&;chunk,string,sizeof(Chunk));//1字节掩码技巧(适用于4对单位数)std::uint64_t LOWER_Digits=(chunk&;0x0f000f000f000f00)&G。Chunk=LOWER_DIGITS+UPER_DIGITS;//2字节掩码技巧(适用于2对两位数)LOWER_DIGITS=(CHUNK&;0x00ff000000ff0000)>;>;16;UPER_DIGITS=(CHUNK&;0x000000ff000000ff)*100;CHUNK=LOWER_DIGITS+UPER_DIGITS;//4字节掩码技巧(适用于四位数对)LOWER_。UPPER_DIGITS=(块&;0x000000000000ffff)*10000;CHUNK=LOWER_DIGITS+UPER_DIGITS;RETURN CHUNK;}。
总而言之,要解析16位整数,我们将其分成两个8字节的块,运行我们刚刚编写的parse_8chars,并对其进行基准测试!
内联std::uint64_t parse_trick(std::string_view s)无异常{std::uint64_t上部数字=parse_8_chars(s.。data());std::uint64_t LOWER_DIGITS=PARSE_8_CHARS(s.。Data()+8);返回UPER_DIGITS*100000000+LOWER_DIGITS;}静态空BM_TRICK(Benchmark::State&;state){对于(AUTO_:STATE){Benchmark::doNotOptimize(PARSE_TRICH(Example_StringView));}}。
不算太差,我们几乎将展开的循环基准降低了56%!尽管如此,感觉就像我们在手动进行一系列掩蔽和基本操作。也许我们可以让CPU来做所有的繁重工作?
我们还需要解析一个16个字符或128位的字符串-我们可以使用SIMD吗?我们当然可以!SIMD代表单指令多数据,这正是我们正在寻找的。英特尔和AMD CPU均支持SSE和AVX指令,并且它们通常与更宽的寄存器配合使用。
我使用英特尔入门指南为正确的SIMD CPU指令查找正确的编译器内部函数。
inline std::uint64_t parse_16_chars(const char*string)noExcept{auto chunk=_mm_ldddu_si128(reInterprete_cast<;const__m128i*>;(String));auto zeros=_mm_set1_ep8(';0';);chunk=chunk-zeros;//.}。
现在,节目的明星是MADD功能。这些SIMD函数与我们使用位掩码技巧所做的完全一样-它们采用宽寄存器,将其解释为较小整数的向量,将每个乘以给定的乘数,然后将相邻的乘数相加成较宽整数的向量。所有这些操作都在一条指令中完成!
作为获取每个字节、将奇数乘以10并将相邻对相加的示例,我们可以使用_mm_maddubs_ep16。
//一条指令中的1字节诀窍const auto mult=_mm_set_ep8(1,10,1,10,1,10,1,10,1,10,1,10,1,10);chunk=_mm_maddubs_ep16(chunk,mult);
还有另一条用于2字节技巧的指令,但不幸的是,我找不到用于4字节技巧的指令-它需要两条指令。以下是完整的parse_16_chars:
inline std::uint64_t parse_16_chars(const char*string)noExcept{auto chunk=_mm_lddqusi128(reInterprete_cast<;const__m128i*>;(String));auto zeros=_mm_set1_ep8(';0';);chunk=chunk-zeros;{const auto mult=_mm_set_ep8(1,10,1,10,1,10,1,10,1,10,1,10,1,10,1,10,1,10);chunk=_mm_maddubs_ep16(chunk,mult);}{const auto mult=_mm_set_ep16(1,100,1,100,1,100,1,100);块=_mm_madd_ep16(块,多个);}{块=_mm_Packus_ep32(块,块);常量自动多个=_mm_set_ep16(0,0,0,0,1,10000,1,10000);块=_mm_madd_ep16(块,多个);}RETURN((块[0]&;0xffffffff)*100000000)+(块[0。32);}。
一些评论者抱怨说,这只是一个玩具问题,没有输入验证或长度检查,而且这只适用于固定长度的整数。首先,当您知道整数很长时,这可以用作“快速路径”,而在其他情况下,它可以后退到一个简单的循环上。其次,我敢打赌,再用几条聪明的SIMD指令,你就可以让程序在2纳秒内运行,并完成验证和长度检查。
编译器绝对是一项令人惊叹的技术。他们经常让我惊讶(甚至让我大吃一惊),因为他们可以很好地优化代码,并看透我正在做的事情。在为这篇文章准备基准时,我遇到了一个问题-如果我试图在基准中解析的字符串对同一编译单元中的编译器是可见的,无论我做什么,GCC和clang都会在编译时评估我的解决方案,并将最终结果放入二进制文件中。甚至是SIMD实现!我的所有基准测试结果都相当于一条mov指令。我不得不将整数字符串放在单独的编译单元中,但我打赌如果我打开LTO(链接时间优化),编译器也会将其优化。
话虽如此,这里有一种“优化是阿列维尔之根”的文化。那种手写汇编或手工优化已经没有立足之地了,我们应该盲目地依赖我们的编译器。我认为这两种观点是互补的-信任你的编译器,信任你的库供应商,但是当你知道你的输入,并且你已经做了测量,知道它会有什么不同时,没有什么比仔细考虑的代码更好的了。
想象一下,您的业务围绕解析大量遥测数据,而您选择使用std::stringstream。您会购买更多的服务器,还是会花一点时间优化您的解析?
所有的基准测试都运行在3.8GHzAMD Ryzen 3900X上,运行在LINUX上,编译版本为GCC。
从2014年开始,在Mac上运行Intel处理器,并使用Clang编译,非SIMD技巧实际上比朴素循环运行得慢。SIMD技巧仍然是最快的。
我省略了基于表的解析方法,因为我相信它们会比这里的SIMD方法慢,因为它们需要更多的数据缓存使用,而且坦率地说,我懒得填写那些表。如果人们认为相反,我可以花一些时间将其添加到这些基准中。
如果有什么不清楚的地方,请在评论中告诉我-我会尽量澄清的。
事实证明,字节跳动是完全没有必要的!它帮助我在思考问题时获得了下一个洞察力,但交换可能会作为水平加法的一部分发生。我会更新基准编号,并在接下来的几天更新这篇文章。
还发现了一个聪明的家伙Wojciech Muł,他已经想到了这一点!我非常高兴我们在相同的方法上趋同--归根结底是相同的SSE指令。