0%

记一个 cgo 优化,C 调 Go 快十倍

从去年 3 月 15 日第一次提交,到昨天被合并,预计下一个 1.21 版本可以发布,这个 cgo 优化 搞了一年多,经历了各种波折坎坷,从一个 600 多行的补丁,折腾到了 200 多行,最后到合并的时候,又成了 700 多行,还是挺不容易的。

尤其是,这周是 Go team 的 Quiet Week,一般是不处理外部事务的,但是 Cherry 大佬还是一直在 review,也是挺让我感动的,respect

按照崔老师的话说,这波是逆风局了,因为这个优化可能并不是 Google 内部需要的,或者 Go team 所看重的,能走到现在也是受了很多人的帮助,这里记录下过程,踩过的坑,也对给予过帮助的人,表达谢意

起因

前年底加入 MOSN 团队,开始搞 MoE 以来,就一直有在折腾 cgo,因为 MoE 是重度依赖 cgo,并且是业界少见的,有点把 Go 当做嵌入式,这么频繁的跟 C 宿主交互的玩法。

去年写过这篇文章,详细介绍过这个优化 ,感兴趣的可以去仔细了解。

简单来说,就是 c 调 Go 的时候,需要在 C 线程上伪装 Go runtime 所需要的 GMP 环境,每次从 C 进入 Go,会用 needm 来获取 M 和 g,从 Go 返回 C,会用 dropm 来释放 M 和 g。然而,needmdropm 很重的操作,这个优化也很简单,就是复用,只在 C 线程退出的时候,才释放。

PreBindExtraM C API

起初,对这块机制也不是那么清楚,就搞了个 新增 API 来主动开启优化的提案 ,也就是 C 可以主动调用 PreBindExtraM 来提前绑定 M,然后这个 M 就一直不会被释放了。

经过 ian 和 aclements 两位大佬的提醒,原来注释里 rsc 大佬提了一个 TODO,从 Go 返回 C 的时候,可以不释放 M,但是前提是,需要用 pthread_key_create 注册一个 destructor,在线程退出的时候,可以释放 M,否则,M 就可能会泄漏了。

除了像 Windows 这种,不支持注册 pthread destructor 的系统,都启用优化,也就是第一次获取到 M 之后,就不再释放,直到 C 线程退出。

全部 CPU 体系跑通

接下来的主要工作,主要是通过 destructor,在线程退出的时候,释放 M 了。

因为 destructor 是 os 触发的,使用的是 C function call ABI,但是 dropm 是在一个 Go 函数,自然是 Go function Call ABI,这两者的寄存器使用是两套约定。

所以,参考了 C 调用 Go 会用到的 crosscall2 的实现,引入了 cgodropm 函数,将 C 里面的 callee-save, Go 里面的 caller-save 全部压栈,再调用 dropm。

在 ian 的帮助下,先是跑通了 amd64,然后又跑通了十来个 CPU 体系结构。此时已经是 600 多行的补丁了。

虽然,大部分的汇编是从 crosscall2 抄过来,不过也踩了一些坑,其中印象最深的是 arm64,stack pointer 必须是 16 byte 对齐的,否则会抛 bus error 异常了。

复用 crosscall2

然后是 Michael 大佬来了,他坚持不想要这个大段汇编的 cgodropm,改为复用 crosscall2 + hack cgocallback

这… 几百行的汇编,也没办法,大佬坚持要改,只好重新改了。改完之后,又瘦身到 200 多行的补丁了。

不过,这里依然有一个问题,crosscall2 只是导出给外部 link 的 C 程序使用,并没有导出给 runtime 的 C 程序使用,在这里又开始折腾了好久。

为此研究了一番,Go 和 C 之间的符号导出机制,link 的工作机制。

折腾了一番,搞了个 cgo_crosscall2 的 wrapper 函数,导出给 runtime 的 C 程序使用,但是大佬不认。

runtime C 直接调用 crosscall2

这时 Cherry 大佬出来了,坚持认为应该在 runtime C 代码里面直接调用 crosscall2,如果有问题的话,就是 compiler 或者 link 哪里有问题。

好吧,只提了要求,但是也没有太具体的指导,还是只能自己折腾。

这回是研究了 compiler 的流程,cgo compiler 编译出来的中间结果,会通过 gcc ld 来会判断是否依赖外部符号,原来的 runtime C 是不会引用外部函数的,但是 crosscall2 是在汇编里实现的,在 runtime C 里加了 crosscall2 的调用,这下就捅了马蜂窝了,gcc ld 认为是依赖外部符号的,发生了连锁反应,后面的编译测试都有问题了。

经过了一通折腾,最后在 Cherry 大佬的提议下,搞了一个小 hack,在 cgo compiler 的时候,临时搞个假的 crosscall2 欺骗 gcc ld。

然而,Cherry 大佬最后又反水了,不想这么搞了,因为会在 compiler 流程里,做一些 hardcode 的 hack …

函数指针变量

这时候,Cherry 大佬,希望让我试下,通过汇编将 crosscall2 的函数地址,写入到 C 里面的函数指针变量。

这… 不是跟上一版差不多么?然而,大佬坚持,而且也表达了歉意,那也没辙了,继续折腾。

此时,Go 摸得也差不多,并且大佬也给出了比较具体的建议,所以搞起来,也还算比较顺利。

这一版里,Go 不需要获取 crosscall2 的值,改为提供一个汇编函数,将 crosscall2 写入一个 C 函数指针变量,确实看起来也更干净了。

虽然也费了不少劲,但是还算比较顺利,又通过了测试了。并且 Cherry 和 Ian 两位大佬都给了 approve。

Slow trybots

看起来比较接近了,这次热心网友 thepudds 提议要不要跑一下 slow trybots,也就是一些比较少见的测试环境,包括一些听都没听过的操作系统,CPU 架构。

说到 thepudds,也挺让我感动的,从 PR 一开始就有参与讨论,可前期并没有参与,但是到了后期,我提了 patch,基本很快就帮我 run trybots。

Go 并不是默认就会跑测试,而是需要有权限的人来触发,如果要等 Go team 的人来确认,又是多了一天的往返,所以,有段时间我是请崔老师帮忙,thepudds 热心之后,崔老师也没那么烦我了,哈哈

说到 slow trybots,去年提过一个补丁,合并之后,就是因为 slow trybots 失败了,直接被 revert 了。

有了这种惨痛教训,我也挺希望跑跑 slow trybots,实在不想再被 revert 了。

macOS m1

好吧,果然发现了一些失败,第一个就是 macOS m1。并不是 arm64 都有问题,只有 macOS m1 才能复现…

好在跟公司 IT 临时借了个 m1 …

最后定位是,macOS m1 上,对于 TLS 变量的顺序不太一样,看起来是先清理了 Go 侧的 TLS,然后才调用的 pthread 注册的 destructor …

好吧,又是一通改,那就先把 m 存到 C 侧的 TLS,不再依赖 os 的 clean 顺序了。

AIX ppc64

还有另一个失败,则是完全没听过的 AIX ppc64 环境,这是 IBM 搞的操作系统和 CPU。

只有 AIX 这个系统还有点文档,仔细查阅之后,也没有发现有啥差异 … ppc64 的机器,那是别想搞到手的了 …

就在一筹莫展之际,无意间发现 master 分支上,有一个 ppc64 相关的变更,一看是 ibm 的邮箱,并且还有 +2 的 approve 权限。

尝试发了个邮件,请求帮助,很幸运大佬回复了,在大佬的指引和帮助下,这个兼容性也修复了。

简单来说,AIX 使用的 function call 还是 ELF v1 版本,function call 并不是直接 call 函数地址,而是搞一个 function descriptor。

合并

搞完这些兼容性问题,就是上周了。

等了几天,Cherry 大佬还没回应,这周又 ping 了一次,thepudds 提醒这周是 Quiet Week,心想,好吧,再等一周吧。

很惊喜的是,Cherry 大佬居然出来了,又 review 几轮,基本每天一往返,非常高效。

就在昨天,就这么被合并了,哈哈,真心不容易。

并且,合并之后,还有一个 arm 的 slow trybots 失败了,大佬也很给力,没有 revert,直接帮忙给修了,欧力给!

早上起来发现补丁被合并之后,激动得立马请 chatgpt 写了一段彩虹屁,好好夸了一番,哈哈。

感受

最大的一个感受,be nice!

整体过程中,大佬们大多是不太积极的,不过也可以理解,大佬也是 Google 的打工人,有自己的工作,这种菜鸟提的 patch,谁知道你会不会弃坑呢。

好在从一开始,崔老师就给我打过预防针,这个会很难,有一定的心理预期,所以过程中,肯定有不爽的时候,尤其是各种 ping,发邮件都没有回应的时候,然而,keep nice 我想我还是做到了,哈哈。

好在最后大佬们看到我的耐心,也开始变得积极了,信任也是这么一点点积累出来的。

最后

哈哈,流水账记录了不少,虽然走了不少弯路,但是其中踩过的坑,都是成长,记录下来,也是希望能有一些借鉴意义。

总体来说,这个优化其实也不难,为了搞一个简单的优化,把 compiler,link,runtime 研究了一遍。

有点像,当年搞一个 LuaJIT 的小 bug,把 LuaJIT 的 c code,byte code,IR code,assembly code 研究了一遍。

虽然比较折腾,不过还是蛮有意思的,相信后面还可以给 cgo 搞更多的优化

如果你也重度依赖 cgo 欢迎一起交流~