找回密码
 会员注册
查看: 11|回复: 0

百度C++工程师的那些极限优化(内存篇)

[复制链接]

2万

主题

0

回帖

7万

积分

超级版主

积分
71086
发表于 2024-10-8 19:50:44 | 显示全部楼层 |阅读模式
导读:在百度看似简简单单的界面后面,是遍布全国的各个数据中心里,运转着的海量C++服务。如何提升性能,降低延时和成本就成了百度C++工程师的必修功课。伴随着优化的深入攻坚,诞生并积累下来一系列的性能优化理论和方案,其中不乏一些冷门但精巧实用的经验和技巧。本文从内存访问角度,收集总结了一些具有通用意义的典型案例,分享出来和大家学习交流。1? 背景在百度看似简简单单的界面后面,是遍布全国的各个数据中心里,运转着的海量C++服务。对C++的重度应用是百度的一把双刃剑,学习成本陡峭,指针类错误定位难、扩散性广另很多开发者望而却步。然而在另一方面,语言层引入的额外开销低,对底层能力可操作性强,又能够为追求极致性能提供优异的实践环境。因此,对百度的C++工程师来说,掌握底层特性并加以利用来指导应用的性能优化,就成了一门必要而且必须的技能。久而久之,百度工程师就将这种追求极致的性能优化,逐渐沉淀成了习惯,甚至形成了对技术的信仰。下面我们就来盘点和分享一些,在性能优化的征途上,百度C++工程师积累下来的理论和实践,以及那些为了追求极致,所发掘的『奇技淫巧』。2? 重新认识性能优化作为程序员,大家或多或少都会和性能打交道,尤其是以C++为主的后端服务工程师,但是每个工程师对性能优化概念的理解在细节上又是千差万别的。下面先从几个优化案例入手,建立一个性能优化相关的感性认识,之后再从原理角度,描述一下本文所讲的性能优化的切入角度和方法依据。2.1? 从字符串处理开始2.1.1? string as a buffer为了调用底层接口和集成一些第三方库能力,在调用界面层,会存在对C++字符串和C风格字符串的交互场景,典型是这样的:size_t some_c_style_api(char* buffer, size_t size);void some_cxx_style_function(std::string& result) { // 首先扩展到充足大小 result.resize(estimate_size); // 从c++17开始,string类型支持通过data取得非常量指针 auto acture_size = some_c_style_api(result.data(), result.size()); // 最终调整到实际大小 result.resize(acture_size);}这个方法存在一个问题,就是在首次resize时,string对estimate_size内的存储区域全部进行了0初始化。但是这个场景中,实际的有效数据其实是在some_c_style_api内部被写入的,所以resize时的初始化动作其实是冗余的。在交互buffer的size较大的场景,例如典型的编码转换和压缩等操作,这次冗余的初始化引入的开销还是相当可观的。为了解决这个问题,大约从3年前开始,已经有人在持续尝试推动标准改进。http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p1072r7.html注:在这个问题上使用clang + libc++的同学有福,较新版本的libc++中已经非标实现了resize_default_init功能,可以开始尝鲜使用。在标准落地前,为了能够在百度内部(目前广泛使用gcc8和gcc10编译器)提前使用起来,我们专门制作了适用于gcc的resize_uninitialized,类似于上面的功能,在百度,可以这样编码:size_t some_c_style_api(char* buffer, size_t size);void some_cxx_style_function(std::string& result) { auto* buffer = babylon::resize_uninitialized(result, estimate_size); auto acture_size = some_c_style_api(buffer, result.size()); result.resize(acture_size);}2.1.2??split string实际业务中,有一个典型场景是一些轻schema数据的解析,比如一些标准分隔符,典型是'_'或者'\t',简单分割的分列数据(这在日志等信息的粗加工处理中格外常见)。由于场景极其单纯,可能的算法层面优化空间一般认为较小,而实际实现中,这样的代码是广为存在的:std::vector tokens;// boost::splitboost::split(token, str, [] (char c) {return c == '\t';});// absl::StrSplitfor (std::string_view sv : absl::StrSplit(str, '\t')) { tokens.emplace_back(sv);}// absl::StrSplit no copyfor (std::string_view sv : absl::StrSplit(str, '\t')) { direct_work_on_segment(sv);}boost版本广泛出现在新工程师的代码中,接口灵活,流传度高,但是实际业务中效率其实并不优秀,例如和google优化过的absl相比,其实有倍数级的差距。尤其如果工程师没有注意进行单字符优化的时候(直接使用了官方例子中的is_any_of),甚至达到了数量级的差距。进一步地,如果联动思考业务形态,一般典型的分割后处理是可以做到零拷贝的,这也可以进一步降低冗余拷贝和大量临时对象的创建开销。最后,再考虑到百度当前的内部硬件环境有多代不同型号的CPU,进一步改造spilt显式使用SIMD优化,并自适应多代向量指令集,可以取得进一步的性能提升。尤其是bmi指令加速后,对于一个SIMD步长内的连续分隔符探测,比如密集短串场景,甚至可以取得数量级的性能提升。最终在百度,我们可以这样编码实现:babylon::split([] (std::string_view sv) { direct_work_on_segment(sv);}, str, '\t'};2.1.3? magic of protobuf随着brpc在百度内部的广泛应用,protobuf成为了百度内部数据交换的主流方式,解析、改写、组装protobuf的代码在每个服务中几乎都会有一定的占比。尤其是近几年,进一步叠加了微服务化的发展趋势之后,这层数据交换边界就变得更加显著起来。在有些场景下,例如传递并增加一个字段,或者从多个后端存储获取分列表达的数据合并后返回,利用标准的C++API进行反序列化、修改、再序列化的成本,相对于实际要执行的业务来说,额外带来的性能开销会显著体现出来。举例来说,比如我们定义了这样的message:message Field { bytes column = 1; bytes value = 2;};message Record { bytes key = 1; repeated Field field = 2;};message Response { repeated Record record = 1; bytes error_message = 2;};我们设想一个场景,一个逻辑的record分散于多个子系统,那么我们需要引入一个proxy层,完成多个partial record的merge操作,常规意义上,这个merge动作一般是这样的:one_sub_service.query(&one_controller, &request, &one_sub_response, nullptr);another_sub_service.query(&another_controller, &request, &another_sub_response, nullptr);...for (size_t i = 0; i (done); auto& resource = closure->memory_resource(); // 做一些请求级别的动态容器 std::pmr::vector tmp_vector(&resource); google::protobuf::Arena::CreateMessage(&(Arena&)resource); ... // done->Run时请求级内存整体释放 }};3.2.2? job reserve更复杂一些的是job reserve方案,在job arena的基础上,结合了job结束后不析构中间结构,也不释放内存,转而定期进行紧凑重整。这就进一步要求了中间结构是可以在保留内存的情况下完成重置动作的,并且能够进行容量提取,以及带容量重新构建的功能。这里用vector为例解释一下:和典型的vector处理主要不同点是,在clear或者pop_back等操作缩减大小之后,内容对象并没有实际析构,只是清空重置。因此,再一次用到这个槽位的时候,可以直接拿到已经构造好的元素,而且其capacity之内的内存也依然持有。可以看到反复使用同一个实例,容器内存和每个元素自身的capacity都会逐渐趋向于饱和值,反复的分配和构造需求都被减少了。了解过protobuf实现原理的工程师可以对照参考,这种保留实例的clear动作,也是protobuf的message锁采用的方法。不过关注到之前提过局部性的工程师可能会发现,尽管分配需求降低了,但是最终饱和态的内存分布在连续性上仍不理想,毕竟中途的动态分配是按需进行,而未能参考局部性了。因此容器还需要支持一个动作,也就是重建。也就是,当重复利用多次,发生了较多临时申请之后,需要能够提取出当前的容量schema,在新的连续空间中做一次原样重建,让内存块重新回归连续。3.3? 总结一下内存分配通过分析malloc的性能原理,引入这两种细粒度的内存分配和管理方案,可以在更小的竞争下,得到更好的内存连续性。在实测中,简单应用做到job arena一般就可以取得大部分性能收益,一般能够达到倍数级提升,在整体服务角度也能够产生可观测的性能节省。而job reserve,虽然可以获得进一步地性能提升,但一方面是因为如果涉及非protobuf容器,需要实现自定义的schema提取和重建接口,另一方面趋于饱和的capacity也会让内存使用增大一些。引入成本会提高不少,因此实际只会在性能更为紧要的环节进行使用。4? 再来看看内存访问内存分配完成后,就到了实际进行内存访问的阶段了。一般我们可以将访存需求拆解到两个维度,一个是单线程的连续访问,另一个是多个线程的共享访问。下面就分拆到两个部分来分别谈谈各个维度的性能优化方法。4.1? 顺序访存优化一般来说,当我们要执行大段访存操作时,如果访问地址连续,那么实际效率可以获得提升。典型例如对于容器遍历访问操作,数组组织的数据,相比于比链表组织的数据,一般会有显著的性能优势。其实在内存分配的环节,我们引入的让连续分配(基本也会是连续访问)的空间地址连续性更强,也是出于这一目的。那么下面我们先来看一看,连续性的访问产生性能差异的原理是什么。这里以Intel CPU为例来简要描述一下预取过程。详见:https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf当硬件监测到连续地址访问模式出现时,会激活多层预取器开始执行,参考当前负载等因素,将预测将要访问的数据加载到合适的缓存层级当中。这样,当后续访问真实到来的时候,能够从更近的缓存层级中获取到数据,从而加速访问速度。因为L1容量有限,L1的硬件预取步长较短,加速目标主要为了提升L2到L1,而L2和LLC的预取步长相对较长,用于将主存提升到cache。在这里局部性概念其实充当了软硬件交互的某种约定,因为程序天然的访问模式总有一些局部性,硬件厂商就通过预测程序设计的局部性,来尽力加速这种模式的访问请求,力求做到通用提升性能。而软件设计师,则通过尽力让设计呈现更多的局部性,来激活硬件厂商设计的优化路径,使具体程序性能得到进一步优化。某种意义上讲,z不失为一个相生相伴的循环促进。这里通过一个样例来验证体现一下如何尊重局部性,以及局部性对程序的影响。// 设计一块很大的存储区域用于存储固定维度的浮点向量// vecs中存储每个浮点向量的起始地址std::vector large_memory_buffer;std::vector vecs;std::shuffle(vecs.begin(), vecs.end(), random_engine);for (size_t i = 0; i flag {0};void relaxed_writer(int i) { payload = flag.load(std::memory_order_relaxed) + i; flag.store(1, std::memory_order_relaxed);}int relaxed_reader() { while (flag.load(std::memory_order_relaxed) == 0) { } return payload;}在使用了基础的内存序等级relaxed之后,编译器不再假设不受其他线程影响,每个循环都会重新加载flag。另外可以观测到flag和payload的乱序被恢复了,不过原理上relaxed并不保证顺序,也就是这个顺序并不是一个编译器的保证承诺。总体来说,relaxed等级和普通的读写操作区别不大,只是保证了对应的内存访问不可省略。更进一步的内存序等级是consume-release,不过当前没有对应的实现案例,一般都被默认提升到了下一个等级,也就是第一个真实有意义的内存序等级acquire-release。先从原理上讲,一般可以按照满足条件/给出承诺的方式来简化理解,即:要求:对同一变量M分别进行写(release)A和读(acquire)B,B读到了A写入的值。承诺:A之前的所有其他写操作,对B之后的读操作可见。实际影响:涉及到的操作不会发生穿越A/B操作的重排;X86:无额外指令;ARMv8:A之前排空store buffer,B之后排空invalid queue,A/B保序;ARMv7&ower:A之前全屏障,B之后全屏障。int payload = 0;std::atomic flag {0};void release_writer(int i) { payload = flag.load(std::memory_order_relaxed) + i; flag.store(1, std::memory_order_release);}int acquire_reader() { while (flag.load(std::memory_order_acquire) == 0) { } return payload;}由于x86默认内存序不低于acquire-release,这里用ARMv8汇编来演示效果。可以看出对应指令发生了替换,从st/ld变更到了stl/lda,从而利用armv8的体系结构实现了相应的内存序语义。再进一步的内存序,就是最强的一级sequentially-consistent,其实就是恢复到了MESI的承诺等级,即顺序一致。同样可以按照满足条件/给出承诺的方式来简化理解,即:要求:对两个变量M,N的(Sequentially Consistent)写操作Am,An。在任意线程中,通过(Sequentially Consistent)的读操作观测到Am先于An。承诺:在其他线程通过(Sequentially Consistent)的读操作B也会观测到Am先于An。实际影响:X86:Am和An之后清空store buffer,读操作B无额外指令;ARMv8:Am和An之前排空store buffer, B之后排空invalid queue,A/B保序;ARMv7:Am和An前后全屏障,B之后全屏障;POWER:Am和An前全屏障,B前后全屏障。值得注意的是,ARMv8开始,特意优化了sequentially-consistent等级,省略了全屏障成本。推测是因为顺序一致在std::atomic实现中作为默认等级提供,为了通用意义上提升性能做了专门的优化。4.5? 理解memory order如何帮助我们先给出一个基本测试的结论,看一下一组对比数据:多线程竞争写入近邻地址sequentially-consistent:0.71单位时间多线程竞争写入近邻地址release:0.006单位时间多线程竞争写入cache line隔离地址sequentially-consistent:0.38单位时间多线程竞争写入cache line隔离地址release:0.02单位时间这里可以看出,做cache line隔离,对于sequentially-consistent内存序下,有一定的收益,但是对release内存序,反而有负效果。这是由于release内存序下,因为没有强内存屏障,写缓冲起到了竞争缓解的作用。而在充分缓解了竞争之后,因为cache line隔离引入了相同吞吐下更多cache line的传输交互,反而开销变大。在这个信息指导下,我们在实现无锁队列时,采用了循环数组 + 分槽位版本号的模式来实现。因为队列操作只需要acquire-release等级,分槽位版本号间无需采用cache line隔离模式设计,整体达到了比较高的并发性能。招聘信息欢迎出色的C++ 工程师加入百度,与大神一起成长。简历投递邮箱:rtad-recruit@baidu.com推荐阅读|前端工程化之H5性能优化篇|趣谈哈希表优化:从规避 Hash 冲突到利? Hash 冲突|百度智能小程序框架性能优化实践「百度Geek说」改名活动火热进行中免费领取小度智能屏1s、小度智能耳机、小度智能音箱扫描下方「百度Geek说」公众号二维码在公众号后台回复:极客获取活动内容详情及文章链接活动截止时间2021年4月26日0点奖品数量有限 快快参与吧!----------? END? ----------百度Geek说百度官方技术公众号上线啦!技术干货?·?行业资讯 ·?线上沙龙?·?行业大会招聘信息 ·?内推信息 ·?技术书籍?·?百度周边欢迎各位同学关注!
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-9 05:55 , Processed in 0.512053 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表