手写汇编,从入门到放弃

2018-03

背景

从去年(2017 年)年底开始一直在学习怎么写高性能计算代码,主要是满足机器学习在线预测的业务需求。作为一个 Pythoner,没人指导,突然转向 C/C++ 领域还是有些许不适应。从最开始的无知者无畏,写过一些吊打 Java 的函数,到最近(2018 年 3 月)见识了 OpenBlas 的手写内联汇编 kernel 之后,算是从入门到放弃了。

编译链接

初入 C/C++ 开发领域,最头疼的就是编译链接,一编译报一堆错误,都不知道发生了些啥。记得以前看过一本名字特别逗的书《程序员的自我修养》,大概内容忘了,只知道讲的编译链接。于是买了本回来大致看了下需要的章节,同时多学习优秀开源项目的 Makefile 和 CMakeLists.txt 怎么写的,查看 GCC 的手册。目前能手写一些简单的 Makefile,修改开源软件的构建过程。相比之下 Rust 的工具链太友好了(原来我这么早就接触 Rust了?),官方指定一条龙。

C/C++

我的 C/C++ 基础仅限于找工作时看过一个月《C++ Primer》,没有正式经验。那个时候看的也大多忘了,没留下啥印象。日常 Python 和 Java 写得比较多,面向对象编程也看过很多遍,真正理解面向对象是怎么回事还是在了解了 C++ 如何实现面向对象之后。当然面向对象实现方式有多种多样,但是我超级赞同不知道在哪看过的观点,编程历史上过程调用算一个抽象,面向对象只能算半个。其实考虑到过程调用有硬件层面的支持(栈帧指令),面向对象跟过程调用比,算半个也有点多了。

由于很多开源机器学习库都是 C++ 写的,所以 C++ 基本是避不开的。正式学习中,各种做人指南看了一通,越看越糊涂,越看越不知道怎么写程序。一个对象传参,返回,赋值,拷贝,构造时到底是怎么回事,不仔细看手册,看代码基本搞不懂。回想各路大神喷 C++,建议用 C,我都有点怀疑人生了。

转折点在对比学习了 Rust 之后,不是说放弃 C++,转向 Rust。而是 Rust的一套清晰的规则,Ownership,Reference,Borrowing,让我弄清了 C++ 那一坨复杂的规则到底想干嘛。C++ 的各种规则抽象其实只是为了方便在组织大型程序时提供帮助,由于要兼容 C 和历史包袱,有些特性就实现得特别别扭。如果某个特性弄不清楚对项目组织有没有帮助,这时直接把C++ 当 C 来使也不是不可以的。现在写 C++,我的脑子里想的其实是 Rust那一套规则。区别只是没有编译器的 Borrow Checker,得人肉 Check。

如果不是为了与现存 C++ 代码打交道,Rust 的开发效率还是很不错的。即使不用,也值得学习。

存储

关于内存和高速缓存,我是反复看《CSAPP》和《What Every Programmer Should Know About Memory》。其中《CSAPP》的存储山可视化方式非常有用,信息量很大。组织良好的程序,应该尽可能处于山峰运行。不过仅限于了解有这么个东西,如果编写缓存友好的程序还处于初级阶段,这点我是在见识了 OpenBlas 的 kernel 之后认识到的。

指令

要实现高性能,基本上就和可移植性告别了。必须充分了解硬件平台,支持的高级指令。要利用高级指令,而不失可移植性最简单的做法是编译选项加 -march=native。其次,简单的编写可以用 Intel Intrinsics Guide, 但是我发现这货有点鸡肋,最后还是得上内联汇编。

内联汇编

编译器有个口号是,生成的汇编可以匹敌普通人(我这种小白)手写的汇编。大部分场景下这句话没毛病,对于高性能计算就不适用了,比如 OpenBlas 库中一个比较简单的 kernel saxpymicrokhaswell-2.c。之前认为只要用上高级指令(AVX),用上浮点寄存器就能提升性能,确实能吊打其他高级语言,但是还不够极致。到了手写汇编这个层次,需要自己规划使用哪些寄存器,指令的顺序,这些以前都是编译器自动分配的。据我所知,ymm* 寄存器有十六个,而这个kernel 只用了五个,难道不是越多越好么?作者如是说:

Hi, most of level-1 blas functions are memory bound.

For the kernel, you want to write, you will need 3 cache lines (for x, y and z) and the cache for z (64 Byte) will be invalidated on write.

But keep in mind, that the bus width from processor to memory is only 128bit.

Sometimes it's better, to use the register xmm* (128bit) instead of ymm*

You can try, to read more values from x,y and z in advance and write back z.

Best regards

Werner

总结

以前只听说过 CPU Bound,IO Bound,还是第一次说 Mermory Bound。类似的还有测量CPU 负载,一般只测量 user/sys time,更细致的还需要测量 CPE。

这时才意识到坑,大坑。手写汇编不难,难的是写出最简且充分利用硬件各种机制的汇编,包括SIMD,高速缓存,内存预取,分支预测,寄存器使用惯例等等我能想到的方面。

通过 compiler explorer 查看了纯 C 和 Intrinsics 生成的汇编和纯手工打造 kernel的差距之后。认识到,目前而言,一切依赖编译器的优化都不可能做到极致。而手写kernel,是个非常复杂的工程。简单的 level1 就有这么多要考虑的,level3还了得。有兴趣的同学可以看看《GEMM: From Pure C to SSE Optimized Micro Kernels》,怎么一步一步优化的。

虽然不打算继续走下去,了解这些东西也是极好的,至少不会愚蠢到以为自己写的 Intrinsics能跟专家的 kernel 比。

最后,计算机体系结构还是很值得一看的,弄明白为什么 CPU 会有这些奇技淫巧。