如何写出更高效的 EmacsLisp 代码

发布于 2024-11-01 13:04:25 字数 7802 浏览 13 评论 0

不是所有的 EmacsLisp 代码都需要优化速度. Emacs 本身就有大约 82%是由 EmacsLisp 来实现的,这些用 EmacsLisp 实现的功能一般都对性能不敏感. 那些对性能有要求的函数都用 C 来实现了。

对 Emacs 的扩展,即时对性能有要求,也只能用 EmacsLisp 来实现,别无他法(当然 dynamic modules 和 调用外部程序这两种方法除外)。

一般自动缩进, 语法树分析 , 以及 自动补全 这几大类插件对性能要求都较高。有 5 条针对 EmacsLisp 的准则可以改善代码性能. 这些准则并不会为了性能而牺牲掉代码的可读性。

需要说明的是: 这些准则只是针对 Emacs 25.1 及相近的版本来说的. Emacs 不断的在改进. 对 虚拟机 和 字节码编译器的修改可能会将当前运行很慢的表达式优化成快速的字节码,从而使得这些准则变得过时。未来如果真的有这种情况发生的话,我会再对本文做出修改。

Use lexical scope

本准则要求你在写 EmacsLisp 时始终将下面这行内容作为文件的首行。

;;; -*- lexical-binding: t; -*-

无论怎么强调这点都不过分. 这不仅仅使得 你的代码更健壮 还能显著地提高代码的运行速度。而且对于那些特殊变量(译者注:defvar,defconst,defcustom 定义的变量) 来说,它们依然是处于动态作用域下的,所以完全没有什么理由不用静态作用域。
你在动态作用域下编写的代码即时切换到动态作用域下也不会影响到它的功能。特殊变量要比局部变量和静态作用域下的变量慢得多,因此请只在必要的时候才用。

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 遍历两次列表要慢得多。

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 是唯一的处理异常的机制。

Prefer using functions with dedicated opcodes

有时你可能会发现要在多个内置函数中选择一个函数来用. 这种情况下尽量选择那些有专用虚拟机操作码的函数,这些函数的调用速度要更加快的多。

那么,你怎么能知道哪些函数是有专门的操作码的呢? 可以看看 bytecomp.el 中列出的那些 byte-defop 语句。

Optimization often involves getting into
the weeds, so don’t be shy.

比方说, assqassoc 这两个函数都会在一个 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 ,而这种情况十分罕见。

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)))))

编译器本身并不知道如何展开循环式. 不过我们完全可以自己用 andor 将其展开。
然后编译器就能生成干净高效的字节码了。

(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 来看看你的热点代码会产生什么样的字节码. 修改一下你的函数然后看看字节码是怎么随之改变的。关注哪些能产生最好字节码的编码形式,然后尽可能地使用这种编码形式进行编码。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

狠疯拽

暂无简介

0 文章
0 评论
288 人气
更多

推荐作者

一梦浮鱼

文章 0 评论 0

mb_Z9jVigFL

文章 0 评论 0

伴随着你

文章 0 评论 0

耳钉梦

文章 0 评论 0

18618447101

文章 0 评论 0

蜗牛

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文