如何写出更高效的EmacsLisp代码
目录
不是所有的EmacsLisp代码都需要优化速度. Emacs本身就有大约82%是由EmacsLisp来实现的,这些用EmacsLisp实现的功能一般都对性能不敏感. 那些对性能有要求的函数都用C来实现了. 对Emacs的扩展,即时对性能有要求,也只能用EmacsLisp来实现,别无他法(当然 dynamic modules 和 调用外部程序这两种方法除外). 一般自动缩进, 语法树分析, 以及 自动补全 这几大类插件对性能要求都较高.
有5条针对EmacsLisp的准则可以改善代码性能. 这些准则并不会为了性能而牺牲掉代码的可读性.
需要说明的是: 这些准则只是针对 Emacs 25.1 及相近的版本来说的. Emacs不断的在改进. 对虚拟机 和 字节码编译器的修改可能会将当前运行很慢的表达式优化成快速的字节码,从而使得这些准则变得过时. 未来如果真的有这种情况发生的话,我会再对本文做出修改.
(1) Use lexical scope
本准则要求你在写EmacsLisp时始终将下面这行内容作为文件的首行.
;;; -*- lexical-binding: t; -*-
无论怎么强调这点都不过分. 这不仅仅使得你的代码更健壮 还能显著地提高代码的运行速度. 而且对于那些特殊变量(译者注:defvar,defconst,defcustom定义的变量)来说,它们依然是处于动态作用域下的,所以完全没有什么理由不用静态作用域. 你在动态作用域下编写的代码即时切换到动态作用域下也不会影响到它的功能.
特殊变量要比局部变量和静态作用域下的变量慢得多,因此请只在必要的时候才用.
(2) Prefer built-in functions
内建函数使用C写的,当然会比用EmacsLisp写得同类函数要快得多. 请尽量用内建函数来完成工作,即使因此会增加代码语句.
举个例子, 创建一个累加队列的最快方法是什么? 也就是说新元素要不断累加到列表的末尾,而且应算法的要求,列表必须从头部开始组建.
你可能会尝试追踪列表的末尾位置,然后使用 setcdr(由下面的setf来调用)
来将新元素直接添加到列表的末尾处.
(defun fib-track-tail (n) (let* ((a 0) (b 1) (head (list 1)) (tail head)) (dotimes (_ n head) (psetf a b b (+ a b)) (setf (cdr tail) (list b) tail (cdr tail))))) (fib-track-tail 8) ;; => (1 1 2 3 5 8 13 21 34)
然而实际上,先创建一个逆序的列表,然后反转它要快得多.
(defun fib-nreverse (n) (let* ((a 0) (b 1) (list (list 1))) (dotimes (_ n (nreverse list)) (psetf a b b (+ a b)) (push b list))))
你可能不相信,但是 nreverse
非常的快. 不仅仅因为它是内建函数,它甚至有自己的操作码!
在循环中使用push然后在最用用nreverse反转列表是创建累加列表的最快方法.
在 fib-track-tail 中, 用EmacsLisp追中列表尾部,不仅增加了复杂度而且要比用C遍历两次列表要慢得多.
(3) Avoid unnecessary lambda functions
这部分内容跟 mapcar
家族的函数比较相关了.
;; Slower (defun expt-list (list e) (mapcar (lambda (x) (expt x e)) list))
很多人喜欢用 dash.el 也喜欢用高阶函数, 但其代价可不菲. 编译器并不知道该如何内联这些匿名函数,因此必然会产生一次函数调用的消耗.
更糟的是, 如果你遵照我前面的建议使用了静态作用域, 上面的例子会创建一个捕获e变量的闭包. 也就是说,每次调用 expt-list
的时候都会用诸如 make-byte-code
这类函数创建一个新的函数对象.
请注意,我并没有说在mapcar中每一次lambda语句都会被重新编译一次 — 所有同样lambda实例都是共用同一个字节码字符串的.
但每次调用 expt-list
确实都会分配和初始化一个唯一的函数数组(#[...])以及多个常量数组.
这就引入了一个更泛化的规则: 不要在性能相关的代码中产生不必要的垃圾.
让我们把上面的例子改成明确的循环来看看.
(defun expt-list-fast (list e) (let ((result ())) (dolist (x list (nreverse result)) (push (expt x e) result))))
相比之下,它有以下好处
- 不会创建非必须的垃圾对象.
- 没有函数调用的消耗.
这可能是该函数的最快实现了,你应该在性能敏感的代码中使用这个版本的实现.
不过就个人来说,我比较喜欢使用cl-lib中的 cl-loop
.
(defun expt-list-fast (list e) (cl-loop for x in list collect (expt x e)))
cl-loop
宏的扩展结果跟上面的定义差不多,这两种写法在效果上是等价的,就看你习惯怎么写了.
不过使用 cl-loop
来实现高效的循环会更容易一些.
在 Emacs 24.4 及其早期的版本中, catch/throw
会将 catch
中的body转换成一个匿名函数然后在执行这个匿名函数.
如果 catch
中的代码会引用到 catch
之外的变量的话(这很有可能), 那么在静态作用域下,这个lambda函数会转换成一个闭包,结果就像上面说的,会产生待回收的函数对象.
在 Emacs 24.5 及更新的版本中, 编译器启用了一个权限的操作码, pushcatch.
这极大地提高了 catch/throw
的效率,因此你也可以在性能敏感的代码中使用 catch/throw
了.
这很有用,因为 catch/throw
是唯一的处理异常的机制.
(4) Prefer using functions with dedicated opcodes
有时你可能会发现要在多个内置函数中选择一个函数来用. 这种情况下尽量选择那些有专用虚拟机操作码的函数,这些函数的调用速度要更加快的多.
那么,你怎么能知道哪些函数是有专门的操作码的呢? 可以看看 bytecomp.el 中列出的那些 byte-defop
语句.
Optimization often involves getting into
the weeds, so don’t be shy.
比方说, assq
和 assoc
这两个函数都会在一个alist中搜索匹配key的元素. 两个函数都是内建函数,唯一的区别是 assq
使用 eq
来比较key,而 assoc
使用 equal
来比较key.
然而这两者的效率是不一样的: assq
有它自己的操作码(158).
也就是说,在效率敏感的代码中,你应该尽可能使用 assq
, 甚至于你的alist也应该尽可能使用 eq
能比较的类型来作为key. 当然是否真的需要做出这个改变,还是要通过性能测试后才能决定.
另一个类似的例子是 eq
, =
, eql
, 以及 equal
. 有些宏/函数默认使用 eql
来进行比较, 尤其是 cl-lib
package,它从Common Lisp中继承了使用 eql
来比较的这种行为.
比如 cl-case
就是用 eql
来进行比较的,它跟C语言中的 switch
语句很类似.
(defun op-apply (op a b) (cl-case op (:norm (+ (* a a) (* b b))) (:disp (abs (- a b))) (:isin (/ b (sin a)))))
cl-case
会扩展成为一条 cond
语句. 由于Emacs字节码并不支持 jump tables,因此实际上也没有什么优化的空间.
(defun op-apply (op a b) (cond ((eql op :norm) (+ (* a a) (* b b))) ((eql op :disp) (abs (- a b))) ((eql op :isin) (/ b (sin a)))))
然而实际上,在 cl-case
中使用 eql
几乎可以说是最差劲的选择. 在我上面列出的4个判断相等的函数中,只有 eql
是没有自己的操作码的.
相比来说,使用 eq
的速度就要快的多. (理论上, cl-case
完全可以在发现所有比较的key都是symbol的时候,改成用 eq
来作比较.)
(defun op-apply (op a b) (cond ((eq op :norm) (+ (* a a) (* b b))) ((eq op :disp) (abs (- a b))) ((eq op :isin) (/ b (sin a)))))
在EmacsLisp中,你还可以用 eq
来比较整数. 只有在你需要让symbol,整数和浮点数相互进行比较时才需要用到 eql
,而这种情况十分罕见.
(5) Unroll loops using and/or
让我们来看一下下面这个函数,这个函数在一个数字列表中搜索能整除参数的数字,并返回第一个匹配. 我这里使用 %
而不是 mod
的原因在于, %
有它自己的操作码(166),而 mod
没有.
(defun detect (x) (catch 'found (dolist (f '(2 3 5 7 11 13 17 19 23 29 31)) (when (= 0 (% x f)) (throw 'found f)))))
编译器本身并不知道如何展开循环式. 不过我们完全可以自己用 and
和 or
将其展开.
然后编译器就能生成干净高效的字节码了.
(defun detect-unrolled (x) (or (and (= 0 (% x 2)) 2) (and (= 0 (% x 3)) 3) (and (= 0 (% x 5)) 5) (and (= 0 (% x 7)) 7) (and (= 0 (% x 11)) 11) (and (= 0 (% x 13)) 13) (and (= 0 (% x 17)) 17) (and (= 0 (% x 19)) 19) (and (= 0 (% x 23)) 23) (and (= 0 (% x 29)) 29) (and (= 0 (% x 31)) 31)))
在Emacs 24.4及其更早的版本中,那时候 catch
还用得是基于 lambda
的实现, 展开后的实现要比循环的实现快了7倍.
而即使是后来使用了基于 pushcatch
指令的 catch
实现, 展开后的实现速度也是循环的实现的两倍.
也就是说,在第一个实现的函数中,有一半的性能消耗都花费在了循环上了.
应用这条规则时请确定你写得代码真的是对性能有特殊要求. 毕竟,手工展开循环是一件无聊而又易错的工作.
不过我一般不会真的去手工展开这些循环, 使用宏 之类的技术, 来自动生成展开式是一个不错的选择.
(defmacro with-detect (var list) (cl-loop for e in list collect `(and (= 0 (% ,var ,e)) ,e) into conditions finally return `(or ,@conditions))) (defun detect-unrolled (x) (with-detect x (2 3 5 7 11 13 17 19 23 29 31)))
那么我要如何发现还有哪些地方可以优化呢?
使用 M-x disassemble
来看看你的热点代码会产生什么样的字节码. 修改一下你的函数然后看看字节码是怎么随之改变的.
关注哪些能产生最好字节码的编码形式,然后尽可能地使用这种编码形式进行编码.