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

字节跳动GoRPC框架KiteX性能优化实践_UTF_8

[复制链接]

9

主题

0

回帖

28

积分

新手上路

积分
28
发表于 2024-10-1 02:27:16 | 显示全部楼层 |阅读模式
本文选自“字节跳动基础架构实践”系列文章。“字节跳动基础架构实践”系列文章是由字节跳动基础架构部门各技术团队及专家倾力打造的技术干货内容,和大家分享团队在基础架构发展和演进过程中的实践经验与教训,与各位技术同学一起交流成长。KiteX 自 2020.04 正式发布以来,公司内部服务数量 8k+,QPS 过亿。经过持续迭代,KiteX 在吞吐和延迟表现上都取得了显著收益。本文将简单分享一些较有成效的优化方向,希望为大家提供参考。前言KiteX 是字节跳动框架组研发的下一代高性能、强可扩展性的 Go RPC 框架。除具备丰富的服务治理特性外,相比其他框架还有以下特点:集成了自研的网络库 Netpoll;支持多消息协议(Thrift、Protobuf)和多交互方式(Ping-Pong、Oneway、 Streaming);提供了更加灵活可扩展的代码生成器。目前公司内主要业务线都已经大范围使用 KiteX,据统计当前接入服务数量多达 8k。KiteX 推出后,我们一直在不断地优化性能,本文将分享我们在 Netpoll 和 序列化方面的优化工作。自研网络库 Netpoll 优化自研的基于 epoll 的网络库 —— Netpoll,在性能方面有了较为显著的优化。测试数据表明,当前版本(2020.12) 相比于上次分享时(2020.05),吞吐能力 ↑30%,延迟 AVG ↓25%,TP99↓67%,性能已远超官方 net 库。以下,我们将分享两点显著提升性能的方案。epoll_wait 调度延迟优化Netpoll 在刚发布时,遇到了延迟 AVG较低,但 TP99 较高的问题。经过认真研究 epoll_wait,我们发现结合 polling 和 event trigger 两种模式,并优化调度策略,可以显著降低延迟。首先我们来看 Go 官方提供的 syscall.EpollWait 方法:funcEpollWait(epfdint,events[]EpollEvent,msecint)(nint,errerror)这里共提供 3 个参数,分别表示 epoll 的 fd、回调事件、等待时间,其中只有 msec 是动态可调的。通常情况下,我们主动调用 EpollWait 都会设置 msec=-1,即无限等待事件到来。事实上不少开源网络库也是这么做的。但是我们研究发现,msec=-1 并不是最优解。epoll_wait 内核源码(如下) 表明,msec=-1 比 msec=0 增加了 fetch_events 检查,因此耗时更长。staticintep_poll(structeventpoll*ep,structepoll_event__user*events,intmaxevents,longtimeout){...if(timeout>0){...}elseif(timeout==0){...gotosend_events;}fetch_events:...if(eavail)gotosend_events;send_events:...Benchmark 表明,在有事件触发的情况下,msec=0 比 msec=-1 调用要快 18% 左右,因此在频繁事件触发场景下,使用 msec=0 调用明显是更优的。而在无事件触发的场景下,使用 msec=0 显然会造成无限轮询,空耗大量资源。综合考虑后,我们更希望在有事件触发时,使用 msec=0 调用,而在无事件时,使用 msec=-1 来减少轮询开销。伪代码如下:varmsec=-1for{n,err=syscall.EpollWait(epfd,events,msec)ifn 类型来承载 ID 列表,并且 list 的编码方式十分符合向量化的规律,于是我们用了 SIMD 来优化 list 的编码过程。我们使用了 avx2,优化后的结果比较显著,在大数据量下针对 i64 可以提升 6 倍性能,针对 i32 可以提升 12 倍性能;在小数据量下提升更明显,针对 i64 可以提升 10 倍,针对 i32 可以提升 20 倍。减少函数调用inlineinline 是在编译期间将一个函数调用原地展开,替换成这个函数的实现,它可以减少函数调用的开销以提高程序的性能。在 Go 中并不是所有函数都能 inline,使用参数-gflags="-m"运行进程,可显示被 inline 的函数。以下几种情况无法内联:包含循环的函数;包含以下内容的函数:闭包调用,select,for,defer,go 关键字创建的协程;超过一定长度的函数,默认情况下当解析 AST 时,Go 申请了 80 个节点作为内联的预算。每个节点都会消耗一个预算。比如,a = a + 1 这行代码包含了 5 个节点:AS, NAME, ADD, NAME, LITERAL。当一个函数的开销超过了这个预算,就无法内联。编译时通过指定参数-l可以指定编译器对代码内联的强度(go 1.9+),不过这里不推荐大家使用,在我们的测试场景下是 buggy 的,无法正常运行://Thedebug['l']flagcontrolstheaggressiveness.Notethatmain()swapslevel0and1,making1thedefaultand-ldisable.Additionallevels(beyond-l)maybebuggyandarenotsupported.//0:disabled//1:80-nodesleaffunctions,oneliners,panic,lazytypechecking(default)//2unassigned)//3unassigned)//4:allownon-leaffunctions内联虽然可以减少函数调用的开销,但是也可能因为存在重复代码,从而导致 CPU 缓存命中率降低,所以并不能盲目追求过度的内联,需要结合 profile 结果来具体分析。gotest-gcflags='-m=2'-v-test.runTestNewCodec2>&1|grep"functiontoocomplex"|wc-l48gotest-gcflags='-m=2-l=4'-v-test.runTestNewCodec2>&1|grep"functiontoocomplex"|wc-l25从上面的输出结果可以看出,加强内联程度确实减少了一些"function too complex",看下 benchmark 结果:上面开启最高程度的内联强度,确实消除了不少因为“function too complex”带来无法内联的函数,但是压测结果显示收益不太明显。测试结果我们构建了基准测试来对比优化前后的性能,下面是测试结果。环境:Go 1.13.5 darwin/amd64 on a 2.5 GHz Intel Core i7 16GB小包data size: 20KB大包data size: 6MB无拷贝序列化在一些 request 和 response 数据较大的服务中,序列化和反序列化的代价较高,有两种优化思路:如前文所述进行序列化和反序列化的优化以无拷贝序列化的方式进行调用调研通过无拷贝序列化进行 RPC 调用,最早出自 Kenton Varda 的 Cap'n Proto 项目,Cap'n Proto 提供了一套数据交换格式和对应的编解码库。Cap'n Proto 本质上是开辟一个 bytes slice 作为 buffer ,所有对数据结构的读写操作都是直接读写 buffer,读写完成后,在头部添加一些 buffer 的信息就可以直接发送,对端收到后即可读取,因为没有 Go 语言结构体作为中间存储,所有无需序列化这个步骤,反序列化亦然。简单总结下 Cap'n Proto 的特点:所有数据的读写都是在一段连续内存中将序列化操作前置,在数据 Get/Set 的同时进行编解码在数据交换格式中,通过 pointer(数据存储位置的 offset)机制,使得数据可以存储在连续内存的任意位置,进而使得结构体中的数据可以以任意顺序读写对于结构体的固定大小字段,通过重新排列,使得这些字段存储在一块连续内存中对于结构体的不定大小字段(如 list),则通过一个固定大小的 pointer 来表示,pointer 中存储了包括数据位置在内的一些信息首先 Cap'n Proto 没有 Go 语言结构体作为中间载体,得以减少一次拷贝,然后 Cap'n Proto 是在一段连续内存上进行操作,编码数据的读写可以一次完成,因为这两个原因,使得 Cap' Proto 的性能表现优秀。下面是相同数据结构下 Thrift 和 Cap'n Proto 的 Benchmark,考虑到 Cap'n Proto 是将编解码操作前置了,所以对比的是包括数据初始化在内的完整过程,即结构体数据初始化+(序列化)+写入 buffer +从 buffer 读出+(反序列化)+从结构体读出数据。struct MyTest { 1: i64 Num, 2: Ano Ano, 3: list Nums, // 长度131072 大小1MB}struct Ano { 1: i64 Num,}(反序列化)+读出数据,视包大小,Cap'n Proto 性能大约是 Thrift 的 8-9 倍。写入数据+(序列化),视包大小,Cap'n Proto 性能大约是 Thrift 的 2-8 倍。整体性能 Cap' Proto 性能大约是 Thrift 的 4-8 倍。前面说了 Cap'n Proto 的优势,下面总结一下 Cap'n Proto 存在的一些问题:Cap'n Proto 的连续内存存储这一特性带来的一个问题:当对不定大小数据进行 resize ,且需要的空间大于原有空间时,只能在后面重新分配一块空间,导致原来数据的空间成为了一个无法去掉的 hole 。这个问题随着调用链路的不断 resize 会越来越严重,要解决只能在整个链路上严格约束:尽量避免对不定大小字段的 resize ,当不得不 resize 的时候,重新构建一个结构体并对数据进行深拷贝。Cap'n Proto 因为没有 Go 语言结构体作为中间载体,使得所有的字段都只能通过接口进行读写,用户体验较差。Thrift 协议兼容的无拷贝序列化Cap'n Proto 为了更好更高效地支持无拷贝序列化,使用了一套自研的编解码格式,但在现在 Thrift 和 ProtoBuf 占主流的环境中难以铺开。为了能在协议兼容的同时获得无拷贝序列化的性能,我们开始了 Thrift 协议兼容的无拷贝序列化的探索。Cap'n Proto 作为无拷贝序列化的标杆,那么我们就看看 Cap'n Proto 上的优化能否应用到 Thrift 上:自然是无拷贝序列化的核心,不使用 Go 语言结构体作为中间载体,减少一次拷贝。此优化点是协议无关的,能够适用于任何已有的协议,自然也能和 Thrift 协议兼容,但是从 Cap'n Proto 的使用上来看,用户体验还需要仔细打磨一下。Cap'n Proto 是在一段连续内存上进行操作,编码数据的读写可以一次完成。Cap'n Proto 得以在连续内存上操作的原因:有 pointer 机制,数据可以存储在任意位置,允许字段可以以任意顺序写入而不影响解码。但是一方面,在连续内存上容易因为误操作,导致在 resize 的时候留下 hole,另一方面,Thrift 没有类似于 pointer 的机制,故而对数据布局有着更严格的要求。这里有两个思路:坚持在连续内存上进行操作,并对用户使用提出严格要求:1. resize 操作必须重新构建数据结构 2. 当存在结构体嵌套时,对字段写入顺序有着严格要求(可以想象为把一个存在嵌套的结构体从外往里展开,写入时需要按展开顺序写入),且因为 Binary 等 TLV 编码的关系,在每个嵌套开始写入时,需要用户主动声明(如 StartWriteFieldX)。不完全在连续内存上操作,局部内存连续,可变字段则单独分配一块内存,既然内存不是完全连续的,自然也无法做到一次写操作便完成输出。为了尽可能接近一次写完数据的性能,我们采取了一种链式 buffer 的方案,一方面当可变字段 resize 时只需替换链式 buffer 的一个节点,无需像 Cap'n Proto 一样重新构建结构体,另一方面在需要输出时无需像 Thrift 一样需要感知实际的结构,只要把整个链路上的 buffer 写入即可。先总结下目前确定的两个点:1. 不使用 Go 语言结构体作为中间载体,通过接口直接操作底层内存,在 Get/Set 时完成编解码 2. 通过链式 buffer 存储数据然后让我们看下目前还有待解决的问题:不使用 Go 语言结构体后带来的用户体验劣化解决方案:改善 Get/Set 接口的使用体验,尽可能做到和 Go 语言结构体同等的易用Cap'n Proto 的 Binary Format 是针对无拷贝序列化场景专门设计的,虽然每次 Get 时都会进行一次解码,但是解码代价非常小。而 Thrift 的协议(以 Binary 为例),没有类似于 pointer 的机制,当存在多个不定大小字段或者存在嵌套时,必须顺序解析而无法直接通过计算偏移拿到字段数据所在的位置,而每次 Get 都进行顺序解析的代价过于高昂。解决方案:我们在表示结构体的时候,除了记录结构体的 buffer 节点,还加了一个索引,里面记录了每个不定大小字段开始的 buffer 节点的指针。下面是目前的无拷贝序列化方案与 FastRead/Write,在 4 核下的极限性能对比测试:测试结果概述:小包场景,无序列化性能表现较差,约为 FastWrite/FastRead 的 85%。大包场景,无序列化性能表现较好,4K 以上的包较 FastWrite/FastRead 提升 7%-40%。后记希望以上的分享能够对社区有所帮助。同时,我们也在尝试 share memory-based IPC、io_uring、tcp zero copy 、RDMA 等,更好地提升 KiteX 性能;重点优化同机、同容器的通讯场景。欢迎各位感兴趣的同学加入我们,共同建设 Go 语言生态!参考资料https://github.com/alecthomas/go_serialization_benchmarkshttps://capnproto.org/https://software.intel.com/content/www/us/en/develop/documentation/cpp-compiler-developer-guide-and-reference/top/compiler-reference/intrinsics/intrinsics-for-intel-advanced-vector-extensions-2/intrinsics-for-shuffle-operations-1/mm256-shuffle-epi8.html字节跳动基础架构团队字节跳动基础架构团队是支撑字节跳动旗下包括抖音、今日头条、西瓜视频、火山小视频在内的多款亿级规模用户产品平稳运行的重要团队,为字节跳动及旗下业务的快速稳定发展提供了保证和推动力。公司内,基础架构团队主要负责字节跳动私有云建设,管理数以万计服务器规模的集群,负责数万台计算/存储混合部署和在线/离线混合部署,支持若干 EB 海量数据的稳定存储。文化上,团队积极拥抱开源和创新的软硬件架构。我们长期招聘基础架构方向的同学,具体可参见 job.bytedance.com (文末“阅读原文”),感兴趣可以联系邮箱:tech@bytedance.com,邮件:姓名 - 工作年限 - 基础架构。欢迎关注「字节跳动技术团队」简历投递联系邮箱「tech@bytedance.com」点击阅读原文,快来加入我们吧!
回复

使用道具 举报

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

本版积分规则

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

GMT+8, 2025-1-13 16:51 , Processed in 0.704189 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

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