接着,在其他模块使用了 Foo 接口,如下:
void bar(Foo &foo) {foo.do_something();}
经过编译后,bar 函数的机器指令伪代码大致如下:
addr = vtable.do_something.addr@foocall *addr
上述伪代码将传入参数 foo 的 do_something 函数的实际地址进行加载,接着对该地址执行一个 call 指令,即动态多态分发的基本原理 。
对于上述例子,在 Speculative Devirtualization 优化中,编译器假设在实际运行中,foo 大概率是 FooImpl 的对象,因而生成的指令中,先判断该假设是否成立,如果成立,则直接调用 FooImpl::do_something(),否则,再走常规的 indirect call,伪代码如下:
addr = vtable.do_something.addr@fooif (addr == FooImpl::do_something)FooImpl::do_something()elsecall *addr
可以看到,上面的伪代码中,获取实际的函数地址后,并没有直接执行一个 indirect call,而是先判断它是不是 FooImpl,如果命中,则可以直接调用 FooImpl::do_something() 。这个例子只有一个子类实现,如果有多个,也是类似会有 if 判断,等所有 if 判断都失败后,最后 fallback 到 indirect call 。
初步看来,这个做法反而增加了指令量,有悖于优化的直觉 。然而,假设大部分调用中,foo 参数的类型都是 FooImpl 的话,实际上只是增加一个地址的比较指令 。并且,由于 CPU 指令的顺序执行特征,这里不会有分支跳转的开销(尽管有个 if) 。进一步地,直接调用 FooImpl::do_something() 与 else 分支中的 call *addr 在高级语言中看起来似乎并没有区别,然而在编译器的视角中是完全不一样的 。这是因为FooImpl::do_something()是明确的静态函数,可以直接应用内联优化,不仅能够省去函数跳转的开销,还可以消除函数实现中不必要的计算 。考虑一个极端场景,假设FooImpl::do_something()的实现是个空函数,经过内联后,整个过程由最开始的一个 indirect call,优化成了只需比较一次函数地址即可结束的过程,这带来的性能差异是巨大的 。
当然,正如这个优化给人的直觉一样 。如果上面 foo 的类型不是 FooImpl,那么这就是个负优化,也正因如此,这个优化在默认情况下基本不会生效,而是要在 PGO 优化中才会被触发 。由于在 PGO 优化中,编译器具备程序在运行期的 profile 信息,其中就包括 indirect call 调用各个实现函数的概率分布,因此编译器可以根据这个信息,针对高概率的函数实现开启该优化 。
PGO 优化实践PGO(Profile Guided Optimization),也称 FDO(Feedback Directed Optimization),是指利用程序运行过程中采集到的 profile 数据,来重新编译程序以达到优化效果的 post-link 优化技术 。其原理认为,对于特征相似的 input,程序运行的特征也相似,因此,我们可以把运行期的 profile 特征数据先采集一遍,再用来指导编译过程进行优化 。
PGO 优化依赖程序运行期所采集的 profile 数据,profile 数据的采集有两种方式,一是编译期插桩(例如 clang 的 -fprofile-instr-generate 编译参数);二是运行期使用 linux-perf 工具采集,并将 perf 的数据转换成 LLVM 可识别的 profile 格式 。对于第二种方式,AutoFDO 是更通用的叫法 。AutoFDO 的整体流程如下图所示:
文章插图
我们的实践采用的是第二种方式:运行期采集 perf。这是因为,如果采用插桩的方式,就只能采集特定 benchmark 的 profile,而不能采集线上真实流量的 profile,毕竟不可能在线上环境运行一个插桩的版本 。PGO 的成功实践极大地促进了 devirtualization 的效果,同时,由于本身也带来了其他的优化机制,获得了 15% 的性能收益,下面介绍我们在 PGO 优化上的重点工作 。
基于 Profile 数据的 PGO 优化基本原理介绍程序运行期采集到的 profile 数据中,记录了该程序的热点函数及指令,这里不做过多展开,以两个简单例子说明它是如何指导编译器做 PGO 优化的 。
virtual 函数 PGO 优化示例第一个例子接着上文中的 Foo 接口 。假设程序中除了有 FooImpl 子类外,还存在 BarImpl 以及其他子类,在 Speculative Devirtualization 优化前,程序是直接获取到实际函数地址后执行 call 指令,而 profile 数据则会记录在所有采集到的这个调用样本中,实际调用了 FooImpl、BarImpl 以及其他子类实现的次数 。例如,该调用点一共被采样 10000 次,其中有 9000 次都是调用 FooImpl 实现,那么编译器认为这里大概率都是调用 FooImpl,就可以针对 FooImpl 开启 Speculative Devirtualization,从而优化 90% 的 case 。可以看出,这个优化对于只有单个实现的 virtual 函数是极佳的,它在保留了未来的 virtual 函数可扩展性的基础上,将其性能优化到与普通直接函数调用无异 。
推荐阅读
- 主机字节序和网络字节序
- 图文教程 微信小程序如何调用后台service
- 脸上跳动是什么原因?
- 内存为什么是以字节为单位的?
- Android面试题集锦之 Service
- 抖音推荐特征系统演进历程
- 什么是微服务?
- Service Worker与缓存
- 虽然「字节跳动」来晚了,但整个车联网行业都在等它
- 汉字节是几月几号