记录一次将 C++ 程序移植到 Common Lisp 上的经历

最近将一个 C++ 的音频处理库(大概 3600 行的代码)花了 5 天的时间成功移植到了 Common Lisp 上。

背景

事情的起因是这样的,最近在做一个项目,主要使用 Common Lisp 进行开发,但发现需要使用到一个 C++ 实时音频处理库提供的功能,这种情况一般是使用 CFFI 通过 C ABI 与 C++ 进行互操作,但该方案麻烦的地方在于:

  1. 与 C++ 进行 FFI 通过 C 的 ABI 交互,需要手写不少接口函数,并且要处理内存的申请与释放的问题。
  2. 每换一个平台都需要先安装对应的 C/C++ 环境进行编译动态链接库,然后才能编译 Lisp 程序,不然 CFFI 会报错。
  3. 每进行一次修改都需要编译然后重启程序才能看到更改的内容,而在 CL 中,程序运行时在 Emacs 下通过一次 C-c C-c 当场就可以看到修改的结果。

因此索性将这个 C++ 项目直接移植到 CL 上,顺便比较一下在算法与程序结构相同的前提下,CL 与 C++ 的性能差距以及 CL 在计算密集应用场景的实用性。

代码翻译

C++ 的很多代码可以很方便地移植到 CL 上,大部分的特性都可以在 CL 找到对应的实现方式,如指针可以表示为 Displaced Array ,模板这些也有专门的库来实现, CLOS 灵活性又比 C++ 的对象系统高得多,所以将 C++ 代码翻译成 CL 代码基本没啥障碍。由于不用手动管理内存,少了不少语法噪音,但 CL 访问类/结构体成员时与有 . 运算符的 C++ 的相比,代码没那么简洁。

CL 在 Lisp 方言中已经算比较接近底层的了,但肯定还是比不上 C/C++ ,比如没办法进行直接内存转译(通过 union 或指针强转),没法直接将 IEEE 浮点类型转换为对应的二进制表示。由于 CL 对结构体/列表/哈希表默认传引用, cl:fill 函数不对拷贝的元素进行复制,会出现改一个元素的属性导致整个数组的元素发生变化的情况,C/C++ 默认传值则没有这个问题,不过解决方法很简单,自己写一个 fill 函数/宏即可,但在 CL 中修改数组元素时确实需要格外谨慎。总之这些都是小问题,不过多少会引入一些性能开销。

性能

只能说 SBCL 不论是成熟度还是性能确实都是 CL 的实现里(甚至是所有 Lisp 方言里)首屈一指的。

对于实时音频处理这种计算密集的应用场景,SBCL 开 (speed 3) 加上 (safety 0) ,根据编译器的提示一步步加类型声明,并且根据 sb-sprof 采样的函数开销进行内联声明,某些模块的性能可以直接追平开 -O3 的 C++。而程序整体的性能 SBCL 与不开优化的 C++差不多, C++ 如果开了 -O3 优化,SBCL 的整体性能大概只有 C++ 的四分之一左右,当然 CL 上我也只优化了调用比较频繁的代码,但是已经可以达到实时处理的效果。

其他的实现就没有这么乐观了,主要还是由于音频和其他媒体不一样,如果说视频与游戏帧率减半勉强能看能玩,音频速率减半的效果基本可以说是惨不忍睹。我这里测了 ECL、CCL、CLASP、ABCL ,其中 ABCL 和 ECL 的结果没问题,但性能连 SBCL 的十分之一都到不了,尤其是 ABCL 在 Java 18 下的性能比 ECL 还差; CCL 总体有 SBCL 三分之一左右的性能,不过 CCL 加类型声明且开启安全检查时居然可能导致简单数值计算得到错误的结果;CLASP 通过 CFFI 调用 API 播放音频采样时报 Memory Fault,不过它的性能基本与 ECL 一个水平;其他专有的实现像 ACL 和 LispWorks 这样的买不起,也不考虑用于项目上了。

总结

个人觉得这次的移植还是比较成功的,毕竟在我的应用场景里,修改、调试与维护 CL 代码要比 C++ 方便得多,性能也还过得去,确实验证了 SBCL 可以在一些性能敏感的场景取代 C/C++ 的使用,不过还是改变不了 Lisp 没什么人用的事实,哈哈。

22 个赞

牛气!不过还是不能理解为啥非要在需要高性能的场景下把C++移植到CL,这不是Lisp的强项啊

可以试试rust,最近业界比较火。Linus也背书了。https://thenewstack.io/rust-in-the-linux-kernel-by-2023-linus-torvalds-predicts/

估计因为主体已经是CL开发的。从CL调用C++很麻烦是真的,基于 kernel+heap 的实现的 CFFI 只能通过 dlopen system call 来加载动态库来实现调用。

确实是这样,用 CL 重写 C++ 代码总比用 C++ 重写 CL 代码要容易得多

Rust 我也挺喜欢的,用它基于 GTK4 做过上位机软件,不过同样没法方便地和 C++ 进行互操作

哦哦,原来这样。主题用CL写,国内实在是太少太少了

好奇问一下, 3600行的C++代码转换成CL是多少行?

3300 行左右,其中包含了一些用于表示 C++ 操作的函数与宏,比如对指针的操作以及内存转译这些。其实比较行数有点不大公平,因为 C-style 的语言习惯把 } 单独放在一行,而 Lisp 的习惯是将所有 ) 和最后一个表达式放在同一行,不过哪怕是在忽略括号的情况下,CL 总体的代码量其实还是比 C++ 多的,毕竟对结构体成员的访问没法使用 . 运算符(虽然 CL 有个库叫做 access 可以提供类似的功能,但会引入运行时开销)

1 个赞

Franz Allegro CL 的数值计算和 Object System 性能应该比 SBCL 还好一点,另外 SBCL 有个大问题是很吃内存,跑几十个进程服务器就内存不够用了。据说 Grammarly 用的是 GC 魔改过后的 SBCL。

1 个赞

有时候为了性能 CLOS 部分用比较少, defstruct 对于单继承场景够用了,然后内存占用的话我这里测的相同代码下 SBCL 的占用是 C++ 的 4 倍左右,对比很多其他高性能的 CL/Scheme 实现(如 Chez/Gambit 这些)已经算是又快又省资源了

经过大量优化后(对热点函数使用内联汇编代替、对更多的函数进行声明内联、尽可能地使用 simple-array 、添加更多的类型声明、将部分对象改为在栈上分配内存), SBCL 达到了开 -O3 C++ 一半的性能。

考虑到 C++ 编译器在高优化等级下会进行各种优化,如重排、虚函数消除、循环向量化等,而这些优化在 SBCL 都是没有的,而且 SBCL 还有 GC 的压力,这样的结果其实已经很不错了。

不过顺便吐槽一下 SBCL ,直到 2.2.11 版本,依旧无法对最基本的算术运算进行化简,如将 (+ (* (+ a b) c) (* (+ a b) d)) 优化为 (* (+ a b) (+ c d)) ,因此如果需要提高性能,则需要手动对算术运算进行化简,有时候会导致代码可读性的下降,而且目前 SBCL 的寄存器分配个人感觉十分不稳定,有时候 let 里声明的顺序改一改也能多几次寄存器的 spill/reload 。

对于 SBCL ,如果还要再往下优化,我能想到的方法还有:

  • 对剩余的 displaced-array 使用指针(取地址使用 sb-kernel:get-lisp-obj-address ,解引用使用 sb-kernel:make-lisp-obj )来代替,并使用 sb-sys::with-pinned-objects 防止垃圾回收器对这些对象进行移动。
  • 使用 sb-simd 手动进行循环 SIMD 向量化。
  • 对于部分数据,使用手动内存管理(通过 sb-alien 或 CFFI 手动进行内存的申请与释放),能够减小 GC 压力的同时,使得在 CL 中无法特化的结构体数组也变成了连续的内存,可以直接进行内存转译和向量化,要知道 SBCL 的 FFI 调用甚至能做到比 C/C++ 还要快的。
  • 使用 SBCL 的块编译避免函数的调用与返回时进行的对象拆装箱。

不过这样的话一来工作量不小,二来优化后的代码完全不具备(实现间的)可移植性,就暂时不去做了,目前 C++ 一半的性能完全可以接受。

4 个赞

大佬啊,怎么感觉对底层这么熟悉。

其实还得归功于 SBCL 允许直接与底层进行交互,比如可以很方便地查看或嵌入一个函数的汇编代码,这样可以很方便地做一些汇编层面的分析,对比 C++ 的版本,找到性能瓶颈并优化,上限是很高的,这也是我这次移植尝试主要的动力与信心的来源。

1 个赞

我有些好奇啊,大佬你的项目是做啥的,为啥会用到lisp去写

之前是想做一个多后端的游戏音频播放器:

至于为啥用 Lisp 或者说是 SBCL ,是因为能够兼顾开发体验、运行性能、部署方便没别的选择。当然对于个人项目而言,最重要的还是 CL 的标准稳定并且可完全自定义,不会随着编程语言的发展而丧失了对整个项目的开发与维护的动力(本人之前是不满 Scala 3 语法改动而退坑的 Scala 爱好者),并且符合 ANSI CL 的代码就算不维护,几十年后照样能运行,当个人项目比较多的时候,低维护成本的优点是十分明显。

1 个赞