2024年Go最全字节跳动 Go RPC 框架 KiteX 性能优化实践(1),面试阿里

img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以戳这里获取

Len:  l,

Cap:  l,

}))

}

这段代码的意思是,先把 string 的地址拿到,再拼装上一个 slice byte 的 header,这样就可以不拷贝数据而将 string 转换成 []byte 了,不过要注意这样生成的 []byte 不可写,否则行为未定义。

预计算

线上存在某些服务有大包传输的场景,这种场景下会引入不小的序列化 / 反序列化开销。一般大包都是容器类型的大小非常大导致的,如果能够提前计算出 buffer,一些 O(n) 的操作就能降到 O(1),减少了函数调用次数,在大包场景下也大量减少了内存分配的次数,带来的收益是可观的。

基本类型

如果容器元素为基本类型(bool, byte, i16, i32, i64, double)的话,由于基本类型大小固定,在序列化时是可以提前计算出总的大小,并且一次性分配足够的 buffer,O(n) 的 malloc 操作次数可以降到 O(1),从而大量减少了 malloc 的次数,同理在反序列化时可以减少 next 的操作次数。

struct 字段重排

上面的优化只能针对容器元素类型为基本类型的有效,那么对于元素类型为 struct 的是否也能优化呢?答案是肯定的。

沿用上面的思路,假如 struct 中如果存在基本类型的 field,也可以预先计算出这些 field 的大小,在序列化时为这些 field 提前分配 buffer,写的时候也把这些 field 顺序统一放到前面写,这样也能在一定程度上减少 malloc 的次数。

一次性计算

上面提到的是基本类型的优化,如果在序列化时,先遍历一遍 request 所有 field,便可以计算得到整个 request 的大小,提前分配好 buffer,在序列化和反序列时直接操作 buffer,这样对于非基本类型也能有优化效果。

定义新的 codec 接口:

type thriftMsgFastCodec interface {

BLength() int // count length of whole req/resp

FastWrite(buf []byte) int

FastRead(buf []byte) (int, error)

}

在 Marshal 和 Unmarshal 接口中做相应改造:

func (c thriftCodec) Marshal(ctx context.Context, message remote.Message, out remote.ByteBuffer) error {

if msg, ok := data.(thriftMsgFastCodec); ok {

msgBeginLen := bthrift.Binary.MessageBeginLength(methodName, thrift.TMessageType(msgType), int32(seqID))

msgEndLen := bthrift.Binary.MessageEndLength()

buf, err := out.Malloc(msgBeginLen + msg.BLength() + msgEndLen)// malloc once

if err != nil {

return perrors.NewProtocolErrorWithMsg(fmt.Sprintf(“thrift marshal, Malloc failed: %s”, err.Error()))

}

offset := bthrift.Binary.WriteMessageBegin(buf, methodName, thrift.TMessageType(msgType), int32(seqID))

offset += msg.FastWrite(buf[offset:])

bthrift.Binary.WriteMessageEnd(buf[offset:])

return nil

}

}

func (c thriftCodec) Unmarshal(ctx context.Context, message remote.Message, in remote.ByteBuffer) error {

data := message.Data()

if msg, ok := data.(thriftMsgFastCodec); ok && message.PayloadLen() != 0 {

msgBeginLen := bthrift.Binary.MessageBeginLength(methodName, msgType, seqID)

buf, err := tProt.next(message.PayloadLen() - msgBeginLen - bthrift.Binary.MessageEndLength()) // next once

if err != nil {

return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())

}

_, err = msg.FastRead(buf)

if err != nil {

return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())

}

err = tProt.ReadMessageEnd()

if err != nil {

return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())

}

tProt.Recycle()

return err

}

}

生成代码中也做相应改造:

func (p *Demo) BLength() int {

l := 0

l += bthrift.Binary.StructBeginLength(“Demo”)

if p != nil {

l += p.field1Length()

l += p.field2Length()

l += p.field3Length()

}

l += bthrift.Binary.FieldStopLength()

l += bthrift.Binary.StructEndLength()

return l

}

func (p *Demo) FastWrite(buf []byte) int {

offset := 0

offset += bthrift.Binary.WriteStructBegin(buf[offset:], “Demo”)

if p != nil {

offset += p.fastWriteField2(buf[offset:])

offset += p.fastWriteField4(buf[offset:])

offset += p.fastWriteField1(buf[offset:])

offset += p.fastWriteField3(buf[offset:])

}

offset += bthrift.Binary.WriteFieldStop(buf[offset:])

offset += bthrift.Binary.WriteStructEnd(buf[offset:])

return offset

}

使用 SIMD 优化 Thrift 编码

公司内广泛使用 list<i64/i32> 类型来承载 ID 列表,并且 list<i64/i32> 的编码方式十分符合向量化的规律,于是我们用了 SIMD 来优化 list<i64/i32> 的编码过程。

我们使用了 avx2,优化后的结果比较显著,在大数据量下针对 i64 可以提升 6 倍性能,针对 i32 可以提升 12 倍性能;在小数据量下提升更明显,针对 i64 可以提升 10 倍,针对 i32 可以提升 20 倍。

减少函数调用
inline

inline 是在编译期间将一个函数调用原地展开,替换成这个函数的实现,它可以减少函数调用的开销以提高程序的性能。

在 Go 中并不是所有函数都能 inline,使用参数-gflags="-m"运行进程,可显示被 inline 的函数。以下几种情况无法内联:

  1. 包含循环的函数;

  2. 包含以下内容的函数:闭包调用,select,for,defer,go 关键字创建的协程;

  3. 超过一定长度的函数,默认情况下当解析 AST 时,Go 申请了 80 个节点作为内联的预算。每个节点都会消耗一个预算。比如,a = a + 1 这行代码包含了 5 个节点:AS, NAME, ADD, NAME, LITERAL。当一个函数的开销超过了这个预算,就无法内联。

编译时通过指定参数-l可以指定编译器对代码内联的强度(go 1.9+),不过这里不推荐大家使用,在我们的测试场景下是 buggy 的,无法正常运行:

// The debug[‘l’] flag controls the aggressiveness. Note that main() swaps level 0 and 1, making 1 the default and -l disable. Additional levels (beyond -l) may be buggy and are not supported.

//      0: disabled

//      1: 80-nodes leaf functions, oneliners, panic, lazy typechecking (default)

//      2: (unassigned)

//      3: (unassigned)

//      4: allow non-leaf functions

内联虽然可以减少函数调用的开销,但是也可能因为存在重复代码,从而导致 CPU 缓存命中率降低,所以并不能盲目追求过度的内联,需要结合 profile 结果来具体分析。

go test -gcflags=‘-m=2’ -v -test.run TestNewCodec 2>&1 | grep “function too complex” | wc -l

48

go test -gcflags=‘-m=2 -l=4’ -v -test.run TestNewCodec 2>&1 | grep “function too complex” | wc -l

25

从上面的输出结果可以看出,加强内联程度确实减少了一些"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 数据较大的服务中,序列化和反序列化的代价较高,有两种优化思路:

  1. 如前文所述进行序列化和反序列化的优化

  2. 以无拷贝序列化的方式进行调用

调研

通过无拷贝序列化进行 RPC 调用,最早出自 Kenton Varda 的 Cap’n Proto 项目,Cap’n Proto 提供了一套数据交换格式和对应的编解码库。

Cap’n Proto 本质上是开辟一个 bytes slice 作为 buffer ,所有对数据结构的读写操作都是直接读写 buffer,读写完成后,在头部添加一些 buffer 的信息就可以直接发送,对端收到后即可读取,因为没有 Go 语言结构体作为中间存储,所有无需序列化这个步骤,反序列化亦然。

简单总结下 Cap’n Proto 的特点:

  1. 所有数据的读写都是在一段连续内存中

  2. 将序列化操作前置,在数据 Get/Set 的同时进行编解码

  3. 在数据交换格式中,通过 pointer(数据存储位置的 offset)机制,使得数据可以存储在连续内存的任意位置,进而使得结构体中的数据可以以任意顺序读写

    1. 对于结构体的固定大小字段,通过重新排列,使得这些字段存储在一块连续内存中
  4. 对于结构体的不定大小字段(如 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 存在的一些问题:

  1. Cap’n Proto 的连续内存存储这一特性带来的一个问题:当对不定大小数据进行 resize ,且需要的空间大于原有空间时,只能在后面重新分配一块空间,导致原来数据的空间成为了一个无法去掉的 hole 。这个问题随着调用链路的不断 resize 会越来越严重,要解决只能在整个链路上严格约束:尽量避免对不定大小字段的 resize ,当不得不 resize 的时候,重新构建一个结构体并对数据进行深拷贝。

  2. Cap’n Proto 因为没有 Go 语言结构体作为中间载体,使得所有的字段都只能通过接口进行读写,用户体验较差。

Thrift 协议兼容的无拷贝序列化

Cap’n Proto 为了更好更高效地支持无拷贝序列化,使用了一套自研的编解码格式,但在现在 Thrift 和 ProtoBuf 占主流的环境中难以铺开。为了能在协议兼容的同时获得无拷贝序列化的性能,我们开始了 Thrift 协议兼容的无拷贝序列化的探索。

Cap’n Proto 作为无拷贝序列化的标杆,那么我们就看看 Cap’n Proto 上的优化能否应用到 Thrift 上:

  1. 自然是无拷贝序列化的核心,不使用 Go 语言结构体作为中间载体,减少一次拷贝。此优化点是协议无关的,能够适用于任何已有的协议,自然也能和 Thrift 协议兼容,但是从 Cap’n Proto 的使用上来看,用户体验还需要仔细打磨一下。

  2. Cap’n Proto 是在一段连续内存上进行操作,编码数据的读写可以一次完成。Cap’n Proto 得以在连续内存上操作的原因:有 pointer 机制,数据可以存储在任意位置,允许字段可以以任意顺序写入而不影响解码。但是一方面,在连续内存上容易因为误操作,导致在 resize 的时候留下 hole,另一方面,Thrift 没有类似于 pointer 的机制,故而对数据布局有着更严格的要求。这里有两个思路:

    1. 坚持在连续内存上进行操作,并对用户使用提出严格要求:1. resize 操作必须重新构建数据结构 2. 当存在结构体嵌套时,对字段写入顺序有着严格要求(可以想象为把一个存在嵌套的结构体从外往里展开,写入时需要按展开顺序写入),且因为 Binary 等 TLV 编码的关系,在每个嵌套开始写入时,需要用户主动声明(如 StartWriteFieldX)。
  3. 不完全在连续内存上操作,局部内存连续,可变字段则单独分配一块内存,既然内存不是完全连续的,自然也无法做到一次写操作便完成输出。为了尽可能接近一次写完数据的性能,我们采取了一种链式 buffer 的方案,一方面当可变字段 resize 时只需替换链式 buffer 的一个节点,无需像 Cap’n Proto 一样重新构建结构体,另一方面在需要输出时无需像 Thrift 一样需要感知实际的结构,只要把整个链路上的 buffer 写入即可。

先总结下目前确定的两个点:1. 不使用 Go 语言结构体作为中间载体,通过接口直接操作底层内存,在 Get/Set 时完成编解码 2. 通过链式 buffer 存储数据

然后让我们看下目前还有待解决的问题:

  1. 不使用 Go 语言结构体后带来的用户体验劣化

    1. 解决方案:改善 Get/Set 接口的使用体验,尽可能做到和 Go 语言结构体同等的易用
  2. Cap’n Proto 的 Binary Format 是针对无拷贝序列化场景专门设计的,虽然每次 Get 时都会进行一次解码,但是解码代价非常小。而 Thrift 的协议(以 Binary 为例),没有类似于 pointer 的机制,当存在多个不定大小字段或者存在嵌套时,必须顺序解析而无法直接通过计算偏移拿到字段数据所在的位置,而每次 Get 都进行顺序解析的代价过于高昂。

    1. 解决方案:我们在表示结构体的时候,除了记录结构体的 buffer 节点,还加了一个索引,里面记录了每个不定大小字段开始的 buffer 节点的指针。

下面是目前的无拷贝序列化方案与 FastRead/Write,在 4 核下的极限性能对比测试:

测试结果概述:

  1. 小包场景,无序列化性能表现较差,约为 FastWrite/FastRead 的 85%。

  2. 大包场景,无序列化性能表现较好,4K 以上的包较 FastWrite/FastRead 提升 7%-40%。

后记

==

希望以上的分享能够对社区有所帮助。同时,我们也在尝试 share memory-based IPC、io_uring、tcp zero copy 、RDMA 等,更好地提升 KiteX 性能;重点优化同机、同容器的通讯场景。欢迎各位感兴趣的同学加入我们,共同建设 Go 语言生态!

参考资料

====

  1. https://github.com/alecthomas/go_serialization_benchmarks

  2. https://capnproto.org/

  3. 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 ,邮件标题: 姓名 - 工作年限 - 基础架构 。

img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以戳这里获取

频、火山小视频在内的多款亿级规模用户产品平稳运行的重要团队,为字节跳动及旗下业务的快速稳定发展提供了保证和推动力。

公司内,基础架构团队主要负责字节跳动私有云建设,管理数以万计服务器规模的集群,负责数万台计算/存储混合部署和在线/离线混合部署,支持若干 EB 海量数据的稳定存储。

文化上,团队积极拥抱开源和创新的软硬件架构。我们长期招聘基础架构方向的同学,具体可参见   job.bytedance.com (文末“阅读原文”),感兴趣可以联系邮箱: tech@bytedance.com ,邮件标题: 姓名 - 工作年限 - 基础架构 。

[外链图片转存中…(img-3BA8ZsHq-1715620692197)]
[外链图片转存中…(img-HVrh5Xoj-1715620692197)]
[外链图片转存中…(img-ZviAmvRY-1715620692198)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以戳这里获取

  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值