前言字节跳动在内部大规模实现了ServiceMesh,提供了RPC、HTTP等流量代理能力,以及丰富的服务治理功能。服务网格架构包括数据平面和控制平面。其中,字节跳动ServiceMesh数据面基于开源Envoy项目重新开发改造,针对主要的流量代理和服务治理功能进行重写。本项目采用C++语言编写。在优化数据面的过程中,我们基于LLVM编译工具链,围绕C++Devirtualization和编译优化进行了更多探索,实现了LTO(LinkTimeOptimization)、PGO(ProfileGuidedOptimization)、C++Devirtualization等编译优化技术。获得了25%的显着性能增益。本文将分享我们在字节跳动ServiceMesh数据平面编译优化方向相关的工作。后台ByteDanceServiceMesh数据面和依赖的Envoy(以下简称meshproxy)为了提供更好的抽象和扩展性,更多的使用了C++虚函数,虽然这样可以给程序编写带来很大的方便,但是编译后生成的机器指令会包含大量的间接调用,每次间接调用都必然需要动态跳转。间接调用过多会导致以下问题:间接指令跳转开销:由于运行实际函数(或接口)的代码地址是动态分配的,机器指令无法进行更多优化,只能直接执行调用指令,这对缓存局部性、指令预执行和分支预测非常不友好。Unabletooptimizeinline:由于虚函数本身的实现是多态的,编译时无法获取实际运行时将要执行的实现,所以无法进行inline优化。同时,在很多场景下,调用一个函数只是为了得到一些返回值或者副作用,但是函数实现通常会进行一些额外的计算,这些可以通过内联优化来消除。由于不能进行内联,间接调用会进行更多的无效计算。阻碍进一步的编译优化:间接调用相当于指令中的一道屏障。由于是只能在运行时确定的调用,会导致编译时各种控制流判断和代码扩展失效,从而限制了进一步的编译和链接优化空间。虚函数虽然会造成很大的性能损失,但是有必要:第一,很多模块本身就需要动态子类实现;第二,将函数模块声明为虚接口对测试编写更友好,方便提供mock实现;第三,C++对虚函数和接口的支持更加成熟,代码结构简单明了。即使是静态多态接口,如果不使用虚函数而是改用模板方式来支持(比如CRTP),代码结构也会极其复杂,上手成本高,难度大维护。考虑到虚函数本身的优势和修改代码结构的成本,我们决定在代码层继续维护虚函数的结构,转而从编译优化的角度优化其性能开销。虚函数的优化(即去虚拟化,或IndirectCallPromotion)大致可以分为三类:LinkTimeOptimization(LTO)、WholeProgramDevirtualization(WPD)和SpeculativeDevirtualization。它们的一般原则如下:LinkTimeOptimization(LTO):链接时优化,在编译阶段生成中间编译对象而不是传统的二进制对象,并保留元信息,然后从全局角度链接所有中间编译对象最后链接阶段,执行跨模块优化方法,生成二进制代码。LTO分为fullLTO和thinLTO。FullLTO主要是串行执行,环节非常耗时。ThinLTO以少量的优化损失换取并发执行模型,大大加快了链路速度。由于LTO在链接阶段具有全局视角,可以进行跨模块类型推导,进行去虚拟化优化。WholeProgramDevirtualization(WPD):通过分析程序中类的继承结构,得到一个虚函数的所有子类实现,并根据这个结果进行去虚拟化。这个优化需要结合LTO来实现,经过实践,优化效果并不理想(后面解释)。SpeculativeDevirtualization:这种优化针对的是一个虚拟的callsite,“推测性地”假设其运行时实现是某个或几个特定的??子类。如果命中,可以直接显式调用相应的实现逻辑,否则,再按照常规的间接调用逻辑。这种优化结合PGO有更好的效果。本文主要关注SpeculativeDevirtualization和PGO优化技术的原理和实践,对LTO和WPD的原理不做过多展开。SpeculativeDevirtualization原理介绍下面举例说明SpeculativeDevirtualization的原理。假设我们写了一个Foo接口和FooImpl的具体实现,如下:structFoo{virtual~Foo()=default;virtualvoiddo_something()=0;};structFooImpl:publicFoo{voiddo_something()override{...}};然后,在其他模块中使用Foo接口,如下:voidbar(Foo&foo){foo.do_something();}编译最后,bar函数的机器指令伪代码大致如下:addr=vtable.do_something.addr@foocall*addr上面的伪代码会加载参数foo传入的do_something函数的实际地址,然后对该地址执行调用指令,即Fundamentalsofdynamicpolymorphicdispatch。对于上面的例子,在SpeculativeDevirtualizationoptimization中,编译器假设在实际运行中,foo很可能是FooImpl的对象,所以在生成的指令中,首先判断假设是否成立,如果成立,直接调用FooImpl::do_something(),否则,采取常规间接调用,伪代码如下:addr=vtable.do_something.addr@fooif(addr==FooImpl::do_something)FooImpl::do_something()elsecall*addr可以看出,上面的伪代码中,在获取到实际的函数地址后,并没有直接执行间接调用,而是先判断是否为FooImpl。如果被命中,它可以直接调用FooImpl::do_something()。在这个例子中,只有一个子类实现。如果不止一个,就会有if判断。全部if判断失败后,fallback最终会去间接调用。最初,这种方法增加了指令量,这与优化的直觉相反。然而,假设在大多数调用中,foo参数的类型是FooImpl,它实际上只是添加了一条地址比较指令。并且,由于CPU指令的顺序执行特性,不会有分支跳转开销(尽管有if)。再者,直接调用FooImpl::do_something()和在else分支调用*addr在高级语言中看起来是一样的,但是从编译器的角度来看是完全不同的。这是因为FooImpl::do_something()是一个明确的静态函数,可以直接应用内联优化,这样不仅节省了函数跳转的开销,也消除了函数实现中不必要的计算。考虑一个极端的场景,假设FooImpl::do_something()的实现是一个空函数。内联之后,整个过程从一开始的间接调用优化为只需要比较一次函数地址就结束的过程。这带来的性能差异是巨大的。当然,就像这个优化给人的直观感受一样。如果上面foo的类型不是FooImpl,那么这是一个负向优化,正因为如此,这个优化基本上默认不会生效,只会在PGO优化的时候触发。在PGO优化中,编译器拥有程序在运行时的profile信息,包括间接调用调用的各个实现函数的概率分布,因此编译器可以根据这些信息对高概率函数进行优化。PGO优化实践PGO(ProfileGuidedOptimization),又称FDO(FeedbackDirectedOptimization),是指利用程序运行过程中收集的profile数据重新编译程序以达到优化效果的链接后优化技术。原则是对于具有相似特征的输入,程序运行的特征也相似。因此,我们可以先在运行时收集profile特征数据,然后用它来指导编译过程进行优化。PGO优化依赖于程序运行期间收集的配置文件数据。有两种收集配置文件数据的方法。一种是在编译时插入存根(比如clang的-fprofile-instr-generate编译参数);另一种是在运行时使用linux-perf工具。收集perf数据并将其转换为LLVM可以识别的配置文件格式。对于第二种方式,AutoFDO是更通用的名称。AutoFDO的整体流程如下图所示:我们的实践采用第二种方式:运行时收集perf。这是因为,如果使用仪器,则只能收集特定基准的配置文件,而不能收集真实在线流量的配置文件。毕竟在线上环境不可能跑一个版本的插桩。PGO的成功实践极大地促进了去虚拟化的效果。同时,由于还带来了其他优化机制,因此获得了15%的性能提升。下面介绍我们在PGO优化方面的主要工作。介绍了基于剖面数据的PGO优化的基本原理。程序运行过程中收集的配置文件数据记录了程序的热点功能和指令。这里我就不展开太多了,用两个简单的例子来说明它是如何引导编译器做PGO的。优化。虚拟函数PGO优化示例第一个示例遵循上面的Foo接口。假设程序中除了FooImpl子类外,还有BarImpl等子类。SpeculativeDevirtualization优化前,程序直接获取实际函数地址并执行调用指令,profile数据会记录在所有收集到的调用中。样本中FooImpl、BarImpl等子类实现实际被调用的次数。比如调用点采样10000次,其中9000次是通过调用FooImpl实现的,那么编译器认为这里调用FooImpl的概率很高,可以为FooImpl启用SpeculativeDevirtualization,从而优化90%个案。可以看出,这种优化对于只有单一实现的虚函数来说是非常优秀的。它将其性能优化为与普通的直接函数调用相同,同时保留了未来虚函数的可扩展性。分支判断PGO优化示例第二个例子是分支判断的优化示例。假设有如下代码片段,判断参数a是否为真,如果是则执行a_staff的逻辑;否则,执行b_staff的逻辑。if(a)//doa_staff...else//dob_staff...return编译时,由于编译器无法假设a为真或假的概率,所以通常输出机器指令相同的块顺序,伪-汇编代码如下所示。其中,首先对参数a进行bool判断,如果为真,则立即执行a_staff的逻辑,然后返回;否则跳转到.else,然后执行b_staff的逻辑。测试a,aje.else;如果a为假则跳转。if:;做一个工作人员...ret.else:;dobstaff...ret在CPU实际执行中,由于指令的顺序执行和流水线预执行等机制,因此,会优先执行紧跟在当前指令之后的下一条指令。以上说明对a_staff有益。如果a为真,则整个流水线一次完成,不会有跳转开销;相反,该指令对b_staff不利。如果a为false,之前在pipeline中预执行的a_staff计算将被废弃,需要从.else中重新加载指令,重新执行b_staff,这些消耗会显着降低指令的执行性能。从上面的分析可以得出,如果在实际运行中a为真的概率比较高,那么代码片段的效率会更高,反之则效率低下。通过收集程序运行过程中的profile数据,可以得到上述分支判断中if分支和else分支实际走的次数。借助这个统计数据,在PGO编译中,如果走else分支的概率高,编译器可以调整输出的机器指令,类似于下面的伪汇编指令,这样对b_staff比较有利。测试a,ajne.if;如果a为真则跳转。否则:;做bstaff...ret.if:;做一个staff...retProfile数据收集和转换为了在mesh代理运行时收集配置文件数据,首先需要正常优化编译以产生二进制文件。为了避免二进制中同名的静态函数符号产生歧义,同时区分同一行C++代码中的多个函数调用,提高PGO的优化效果,我们需要添加-funique-internal-linkage-names和-fdebug-info-for-Profiling这两个clang编译参数,另外需要加上-Wl,--no-rosegment链接参数,否则无法将linux-perf收集的perf数据转换成LLVM需要的格式通过AutoFDO转换工具。编译完成后,选择合适的benchmark或realtraffic运行程序,使用linux-perf工具收集perf数据。经实践验证,在使用linux-perf进行采集时,启用LBR(LastBranchRecord)功能可以获得更好的优化效果。我们使用以下命令为网格代理进程收集性能数据。perfrecord-p-ecycles:up-jany,u-a--sleep60完成perf数据采集后,使用AutoFDO工具(https://github.com/google/autofdo)将perf数据转化为LLVM配置文件格式。create_llvm_prof--profileperf.data--binary--out=llvm.profPGO优化编译获取profile数据后,可以进行最后一步的PGO优化重新编译。需要注意的是,本次编译的源码必须和之前采集profile使用的源码完全一致,否则会干扰优化效果。为了开启PGO优化,只需要添加-fprofile-sample-use=llvm.profclang编译参数,使用llvm.prof文件中的profile数据进行PGO编译优化。PGO编译优化后,meshproxybinary中的间接调用总数减少了80%,基本完成了C++Devirtualization的目标。此外,PGO将根据profile中的热点函数和指令进一步内联,重新排列热点指令和内存,进一步增强常规优化方法,这些都可以为性能带来显着的好处。其他编译和优化工作,全静态链接和LTO实践。在bytemeshproxy达到一定在线规模后,我们在动态链接中遇到了一些问题,包括运行机器的glibc版本可能较低,动态链接的函数调用本身存在额外的开销。考虑到meshproxy本身作为一个独立的sidecar运行,不需要作为其他程序的程序库,我们提出了完全静态链接binary的想法。这样做的好处是:一是可以避免glibc版本问题,二是可以消除动态链接函数的跳转开销,三是可以在全静态链接下进一步应用更多的编译优化。在支持全静态链接后,由于binary没有任何外部库依赖,我们增加了进一步的编译优化,包括将线程本地存储模型更改为local-exec,以及ThinLTO(LinkTimeOptimization)优化。其中,ThinLTO带来了近8%的性能提升。WPD的尝试为了达到去虚拟化的效果,我们也尝试了WholeProgramDevirtualization,但实际效果并不理想,只优化了一小部分间接调用。通过研究LLVM对应模块的实现,我们了解到目前WPD优化只对虚函数生效,实现单一,现阶段无法带来显着的性能收益。BOLT后链接优化在LTO和PGO编译优化的基础上,我们进一步探索了BOLT等后链接优化技术,获得了约8%的性能提升。考虑到稳定性因素,该优化仍在探索和测试中,尚未上线。后记希望以上分享能对社区有所帮助。我们也计划将以上编译优化方法反馈到Envoy开源社区版,参与ServiceMesh领域的建设。参考资料https://people.cs.pitt.edu/~zhangyt/research/pgco.pdfhttps://research.google/pubs/pub45290/https://clang.llvm.org/docs/UsersManual.html#profile-引导优化https://github.com/llvm/llvm-project/tree/main/bolthttps://llvm.org/devmtg/2015-10/slides/Baev-IndirectCallPromotion.pdfhttps://quuxplusone.github.io/blog/2021/02/15/devirtualization/
