解析protobuf在2 + gb / s:我如何学会爱尾呼叫

2021-04-26 10:57:08

一个令人兴奋的功能,刚刚登陆克兰贩运者的主要分支机构。使用[[clang :: musttail]]或__Attribute __((musttail))语句属性,您现在可以在C,C ++和Objective-C中获得保证标准呼叫。

虽然尾部呼叫通常与功能性编程风格相关联,但iam纯粹对它们的性能原因感兴趣。事实证明,我们可以使用尾部呼叫来使用尾部呼叫从编译器中获取更好的代码,否则可能 - 至少给出当前的CompilerteChnology - 而不会丢弃到组装。

将这种技术应用于protobuf解析已经产生了惊人的结果:我们在超过2GB / s的Protobuf Parsings上展示了Protobuf,超过了本领域的双倍。有多种技巧贡献了Tothis Speedup,因此“尾呼叫== 2X加速”是错误的消息。但是尾部呼叫是使得这种加速的关键部分。

在这个博客条目中,我将描述为什么尾呼叫是这样的powerfultechnique,我们如何将它们应用于protobuf解析,以及这种技术如何给口译员解释。我认为,在C(Python,Ruby,PHP,Lua等中写的所有主要Ligulardergers都可能通过采用这种技术来获得重要的优势。主要的下行空位性:目前鼬是一个非标准的编译器扩展,我希望它能够捕获它将是一段时间,因为它的系统的C编译器可能支持它。也就是说,在Buildtime,如果您检测到不可用的情况,则可以损害可移植性的一些效率。

尾呼叫是尾部位置的任何函数调用,在函数返回之前执行的最终动作。当发生尾呼叫优化时,编译器发出尾呼叫的JMP指令而不是呼叫。这跳过通常允许Callee G()Toreturn返回调用者f()的簿记,例如创建新的堆栈帧或推到身边。而是f()直接跳转到g(),好像它是相同函数的一部分,而g()则直接返回给名为f()的任何功能。这一优化是安全的,因为一旦尾呼叫开始,F()的堆栈帧就不会长时间化,因为它不再可能访问F()的局部变量。

虽然这似乎是一个磨坊优化,但它有两个非常重要的属性解锁我们在蜜饯的各种算法中的新可能性。首先,它将堆栈内存从++ O(n)++减少到++ O(1)++,当时++ n ++连续的尾呼叫,这很重要,因为堆栈内存islimited并且堆栈溢出将崩溃您的程序。这意味着除非这种优化缺乏表现,否则肯定的仪器实际上并不安全。其次,JMP消除了呼叫的性能开销,函数调用可以与任何其他分支一样高效。这些曲折使我们能够使用尾部呼叫作为一种有效的替代迭代控制结构,如偶然的替代控制结构。

这绝不是一个新的想法,确实它返回到至少1977年,当圭士座eele写了一个整个纸张,即过程调用比转到更清洁的设计,而且该特征呼叫优化可以像快速一样。这是1975年至1980年间编写的“Lambdapapers”之一,它开发出潜在的Lispand计划的许多想法。

尾部呼叫优化甚至不是铿cl的新内容:就像GCC和许多其他代办者一样,克朗已经能够优化尾呼叫。实际上,上面的第一个示例中的蜜饯属性并没有改变编译器的输出:克朗已经已经优化了-O2下的尾部呼叫。

什么是新的保证。虽然编译器经常会优化尾声Calluccescess,这是最好的努力,而不是你可以依赖的东西。 inparticular,优化很可能不会发生在非优化的建议中:

在这里,尾呼叫被编译为实际呼叫,因此我们返回++ o(n)++堆栈空间。这就是我们需要MustTail的原因:除非我们可以从编译器中获取保证,除非我们的尾部呼叫将始终优化,在所有构建设备中,写入使用尾部呼叫迭代的算法并不安全。对于仅启用Optimizations的代码,它可能是一个非常严重的限制。

编译器是令人难以置信的技术,但它们并不完美。吕吉特的作者Mikepall决定在组装中写下Luajit 2.x的翻译,而他将此决定作为解释的主要因素,该决定解释了Whyluajit的翻译是Sofast的主要因素。他以后与C编译器与Tenderetermain循环斗争的原因有更多详细信息。他的双转体中心是:

函数越大,并且更复杂并连接其控制流程,因此编译器的寄存器分配器难以保持寄存器中的大量数据。

当快速路径和慢速路径混合在相同的函数中时,慢路径的假义会损害快速路径的代码质量。

这些观察结果密切镜像我们的经历优化Protobuf解析。好消息是尾部呼叫可以帮助解决这两个问题。

将解释器循环与Protobuf解析器进行比较可能看起来很奇怪,但是Protobuf线格式的那么使它们比你的强调更相似。 Protobuf Wire格式是一系列标签/值对,其中标签磁场号和线型。此标记与AnInterpretret Opcode类似:它告诉我们我们需要执行的操作以解析菲尔德的数据。与解释器操作码一样,Protobuf字段编号可以在任何时候进入,因此我们必须准备好随时派遣代码的任何部分。

写这样的解析器的自然方式是有一段时间循环围绕交换机语句,并且确实这一直是在Protobufparsing中基本上存在的技术状态,只要Protobufs存在。例如,这里来自当前C ++版本的Protobuf的解析代码。如果我们以图形方式表示T​​hecontrol流量,我们会得到这样的东西:

但这是不完整的,因为在几乎每个阶段都有那些东西的东西出错了。电线类型可能是错误的,或者我们可以看到一些腐败数据,或者我们可以击中当前缓冲区的末尾。所以全文流程图更像是这样的。

我们希望尽可能地留在快速路径(蓝色),但是当威特难以执行一些后退代码来处理它。这些PathaftBack路径通常比快速路径更大,更复杂,触摸更多数据,甚至甚至甚至对其他功能进行单线呼叫,才能处理更复杂的情况。

从理论上讲,与配置文件配对的这种控制流程图应给出要生成最佳代码所需的所有信息。 inpractice,当一个函数很大并连接时,我们经常发现自己的编译器。当我们希望它留在寄存器中时,它会泄漏一个重要变量。它提升了堆栈框架操作,我们要围绕回退函数调用来收缩。它合并我们希望保持分支预测原因的相同代码路径。体验遗憾的是,戴手套时试图在试图踢钢琴。

上面的分析主要只是迈克观察到接近interpreter mainloops的rehash。但是,随着迈克用Luajit 2.x做的,我们发现尾呼吁的设计可以给我们控制近乎最佳的Codefrom C.而不是汇集到大会。我与我的同事们一起努力工作大部分的设计。我们的方法类似于WASM3 WebasseMbly解释器的设计,它将图案描述为“Metamachine”。

我们的2 + GB / s Protobuf解析器的代码被提交给UPB,一个小型Protobuf库写入C,在拉/ 310中。虽然ITIS完全工作并通过所有Protobuf一致性测试,但它尚未滚展,并且设计尚未在C ++版本的普通话中实现。但是,现在Musttail在CLANG(和UPB已经使用它)中,完全生产快速解析器的最大壁垒之一已被删除。

我们的设计与单一大解析功能远离,而是给予每种术语。每个功能尾部都会调用下一个操作序列。例如,这里是解析单个固定范围域的函数。 (此代码是从UPB中的实际代码中简化的;我们设计的许多尾针我要离开这篇文章,但希望在未来的文章中持有潜望。

#include< stdint.h> #include< stddef.h> #include< string.h> typedef void * upb_msg; struct upb_decstate; typedef struct upb_decstate upb_decstate; //传递给每个解析功能的标准参数集。 //由于x86-64调用约定,这些将在寄存器中传递。 #define upb_parse_params \ upb_decstate * d,const char * ptr,upb_msg * msg,intptr_t表,\ uint64_t hasbits,uint64_t data#define upb_parse_args d,ptr,msg,表,hasbits,data #define不可能(x)__builtin_expect(x, 0)#define musttail __attribute __((musttail))const char * hextback(upb_parse_params); const char * dispatch(upb_parse_params); //解析使用1字节标记(字段1-15)的4字节固定字段的代码。 const char * upb_pf32_1bt(upb_parse_params){//解码"数据",其中包含有关此字段的信息。 uint8_t hasbit_index = data>> 24; size_t = data>> 48; if(不可能(数据& 0xff)){//线类型不匹配(调度函数xor' s的预期电线类型//使用实际的电线类型,因此数据& 0xff == 0表示匹配)。 Mustttail退货后备(upb_parse_args); PTR + = 1; //提前过去的标签。 //将数据存储到消息。 hasbits | = 1ull<< hasbit_index; memcpy((char *)msg +,ptr,4); PTR + = 4; //前进过去的数据。 //调用调度函数,它将读取下一个标记和分支到//正确的字段解析器函数。 Musttail返回调度(upb_parse_args); }

对于这个小而简单的函数,Clang为我们的代码提供了不可能击败的代码。

upb_pf32_1bt:#@ upb_pf32_1bt mov rax,r9 shr rax,24 bts r8,rax testr9b,r9b jne .lbb0_1 mov r10,r9 shr r10,48 mov eax,dword ptr [RSI + 1] MOV DWORD PTR [RDX + R10] ,eax添加RSI,5 JMP调度#tailcall.lbb0_1:jmp倒下#tailcall

请注意,没有序列或外表,没有登记泄漏,确实有没有任何堆栈。唯一的出口是来自两个尾随的JMPS,但没有代码转发参数,因为ArgumerSare已经坐在正确的寄存器中。几乎唯一的改进我们可以希望获得尾呼叫的条件跳跃,而不是jne,而不是JMP。

如果您在没有符号信息的情况下查看此代码的拆卸,则无理由知道这是整个功能。它可以轻松成为来自更大功能的基本块。并且本质上讲,正是我们正在做的事情。我们正在拍摄一个解释员循环,即通过块将控制流传输到下一个尾部,通过块是一个扫视的函数和编程它块。我们在每个块边界处拥有寄存器分配的全文(嗯,对于第六晶体至少),只要该功能足够简单到不溢出六个寄存器,我们已经实现了我们在全部寄存器中保持最重要的状态快速的路径。

我们可以独立优化每个指令序列,并且至关重要地,Thecompiler也将每个序列视为独立的序列,因为它们是insepatate函数(如果需要,我们可以防止与诺伊林联线的内联)。这解决了我们之前描述的问题,从后退路径可以降级用于快速路径的代码质量。如果我们将慢速路径放在快速路径中,我们可以保证快速的路径不会受到影响。我们看到的漂亮装配序列是无效的冻结,不受我们对特变格人的其他部分的任何改变的影响。

#define params unsigned ra,void *表,无符号Inst,\ int * op_p,double * consts,double * regs#定义args ra,table,Inst,op_p,consts,regstypedef void(* op_func)(params); void bexprack (params);#定义不太可能(x)__builtin_expect(x,0)#define musttail __attribute __((musttail))void addvn(params){op_func * op_table = table;无符号rc = inst& 0xff; unsigned rb =(Inst>> 8)& 0xFF;无符号类型; Memcpy(&类型,(char *)& regs [rb] + 4,4); if(不太可能(类型> -13)){返回回退(args); } Regs [Ra] + = Const [RC]; inst = * op_p ++; unsigned op = inst& 0xFF; ra =(> 8)& 0xff; >> = 16; musttail返回op_table [op](args);}

addvn:#@addvn movzx eax,dh cmp dword ptr [r9 + 8 * rax + 4],-12 jae .lbb0_1 movzx eax,dl movsd xmm0,qword ptr [r8 + 8 * rax]#xmm0 = mem [0] ,Zero Mov EAX,EDI ADDSD XMM0,QWORD PTR [R9 + 8 * RAX] MOVSD QWORD PTR [R9 + 8 * RAX],XMM0 MOV EDX,DWORD PTR [RCX]添加RCX,4 MOVZX EAX,DL MOVZX EDI,DH SHR EDX,16 MOV RAX,QWORD PTR [RSI + 8 * RAX] JMP RAX#TAYCALL.LBB0_1:JMP倒退

我在这里看到的唯一改进的机会,除了之前提到的jne starkissues,是由于某种原因,编译器不希望JMP QWORD PTR [RSI + 8 * rax]。相反,它更喜欢加载到raxand中,然后用JMP rax遵循。这些是次要代码生成问题,可以在没有太多的工作中固定在铿cl中的问题。

这种方法的最大警告之一是,如果存在任何非尾部呼叫,这些美丽的汇编序列会遭到灾难性困扰。无尾呼叫强制卷起堆栈帧,并且很多数据溢出堆栈。

#define params unsigned ra,void *表,无符号Inst,\ int * op_p,double * consts,double * regs#定义args ra,table,Inst,op_p,const,regs typedef void(* op_func)(params); void倒退(参数); #define不可能(x)__builtin_expect(x,0)#define musttail __attribute __((musttail))void addvn(params){op_func * op_table = table;无符号rc = inst& 0xff; unsigned rb =(Inst>> 8)& 0xFF;无符号类型; Memcpy(&类型,(char *)& regs [rb] + 4,4); if(不太可能(类型> - 13)){//当我们离开时"返回",事情变得非常糟糕。倒退(args); } Regs [Ra] + = Const [RC]; inst = * op_p ++; unsigned op = inst& 0xFF; ra =(> 8)& 0xFF; >> = 16; musttail返回op_table [op](args); }

addvn:#@addvn推送RBP推送R15推拉力推送R13推动R12推拉RBX推送RAX MOV R15,R9VOV R14,R8 MOV RBX,RCX MOV R12,RSI MOV EBP,EDI MOVZX EAX,DH CMP DWORD PTR [R9 + 8 * rax + 4],-12 jae .lbb0_1.lb0_2:movzx eax,dl movsd xmm0,qword ptr [r14 + 8 * rax]#xmm0 = mem [0],zero mov eax,ebp addsd xmm0,qword ptr [r15 + 8 * rax] MOVSD QWORD PTR [R15 + 8 * rax],XMM0 MOV EDX,DWORD PTR [RBX]添加RBX,4 MOVZX EAX,DL MOVZX EDI,DH SHR EDX,16 MOV RAX,QWORD PTR [R12 + 8 * rax] Mov RSI,R12 MOV RCX,RBX MOV R8,R14 MOV R9,R15添加RSP,8 Pop RBX Pop R12 Pop R13 Pop R14 Pop R15 Pop RBP JMP Rax#TailAll.LBB0_1:Mov EDI,EBP MOV RSI,R12 MOV R13D,​​EDX MOV RCX,RBX MOV R8,R14 MOV R9,R15呼叫后退MOV EDX,R13D JMP .LBB0_2

为了避免这种情况,我们试图遵循只呼吁其他职能VIA内联或尾呼叫的学科。如果操作具有多平面点,则这可能会令人讨厌,在此情况可能出现不寻常的情况并非错误。例如,当我们解析Protobuf时,快速和常见的情况是Varints很长,但较长的Varints不是错误。如果倒退代码istoo复杂,则处理不动画的内联危险可能会损害快速路径的质量。但是,一旦处理不寻常的情况,尾部呼叫返回函数就没有易于恢复操作,因此返回功能必须能够推动和完成操作。这表明是代码重复和复杂性。

理想情况下,可以通过将__Attribute __((preserve_most_most))添加到后退函数来解决这个问题,然后通常在没有尾部呼叫的情况下调用它们。Preserve_most属性使得Callee负责保存的所有寄存器,这将登记泄漏的成本移动到FableBack上我们想要的功能。我们尝试了一些attnumebut ran遇到了一些我们无法达到的神秘问题。这可能是我们的错误;重新审视这是蒙太作业。

其他重大限制是Musttail不便携。我很多很多,属性将捕获,传播到GCC,Visual C ++和其他程序编制者,甚至有一天能够标准化。但那一天离现在,所以在此处怎么做?

当不可用的时候,我们需要至少执行一个真正的返回,而无需尾呼叫,对于每个概念循环迭代。我们在upb中实现了这一倒退,但我希望它将涉及尾部呼叫派遣或只是返回的邮件,根据Musttail的可用性。