在前面我们讨论了关于 elisp 的调试和异常相关的内容,关于工程化的内容还需要了解如何对自己的elisp配置进行性能分析,尝试解决某些影响使用的卡顿问题
背景知识
如果我们发现自己的elisp 代码执行卡顿,一般需要经历下面几个步骤来确定范围、分析问题、以及最后解决。
首先可以使用emacs 内置的 Profiler 机制确定问题所在。该机制的启用配套使用的函数有 profiler-start、profiler-report、profiler-stop。
一般使用的步骤如下:
当你发现执行某个命令卡顿严重时,可以先调用 profiler-start 开启性能检测。它的签名如下
emacs-lisp
(profiler-start MODE)
MODE 参数可以是 'cpu、'memory 或者 'cpu+mem
之后我们可以执行导致卡顿的函数或者操作。
接着调用 profilter-report 停止采样并弹出一个类似火焰图/调用树的剖析报告缓冲区。
分析完毕之后可以调用 profiler-stop 停止Emacs内部的采样,避免持续开销。
我们以一个例子来说明如何解读数据
emacs-lisp
(defun my-inefficient-string-join (n)
(let ((res ""))
(dotimes (i n)
(setq res (concat res (number-to-string i))))
res))
(defun my-test-heavy-profiling-task()
(interactive)
(profiler-start 'cpu+mem)
;; 调用函数执行任务
(my-inefficient-string-join 5000)
;;停止任务并展示报告
(profiler-report)
(profiler-stop))
我们可以得到两个界面,一个显示cpu耗时、一个显示内存,它们的内容如下:
txt
46,779,444 99% - command-execute
46,779,444 99% - funcall-interactively
46,779,444 99% - execute-extended-command
46,779,444 99% - command-execute
46,779,444 99% - funcall-interactively
46,779,444 99% - my-test-heavy-profiling-task
45,218,433 96% + my-inefficient-string-join
1,560,380 3% profiler-report
631 0% profiler-start
46,984 0% Automatic GC
0 0% ...
46 100% Automatic GC
0 0% ...
从这里我们可以看出耗时操作主要是调用 my-inefficient-string-join 它占据了96%的CPU时间。前面带有 + 的可以按 tab 进一步展开。如果我们进一步展开就可以发现,函数内部的主要耗时是调用 concat 函数。
确定了可疑函数,我们就可以调用 elp 相关函数监控这两个函数的具体调用情况:
- 定义一个存储函数符号的list 变量,表示需要监控哪些函数
elp-instrument-list开始监控,可以传入上一步定义的列表变量,或者默认使用elp-function-list中保存的函数elp-results查看监控结果- 最后调用
elp-restore-function恢复部分函数或者调用elp-restore-all恢复所有函数
因此我们可以修改一下上述的函数,重点监控 my-inefficient-string-join
emacs-lisp
(defun my-test-heavy-profiling-task()
(interactive)
(setq elp-function-list '(my-inefficient-string-join))
(elp-instrument-list elp-function-list)
;; 调用函数执行任务
(my-inefficient-string-join 5000)
;;停止任务并展示报告
(elp-results)
(elp-restore-all))
执行之后我们可以发现调用结果如下::
txt
my-inefficient-string-join 1 0.934222 0.934222
concat 5000 0.8685359999 0.0001737071
结合具体代码来看,主要的消耗在 my-inefficient-string-join 内部调用 concat。concat 每一次的消耗可能并不多。
事情真的是这样吗?
elp 无法统计每一次调用concat 真实的消耗,上面显示的只是平均每次的消耗。也就是说它是通过 0.8685359999 / 5000 得到的平均值。实际上我们知道 concat 的实现机制是每次追加都会经历:分配新内存-->拷贝原始字符串到新内存-->删除旧的内存块-->追加字符。也就是说,随着原字符串越来越大,每次拷贝的开销会越来越大。也就说随着调用次数的增加,它的时间复杂度是 O(n²)。
为了验证这个,我们可以使用 benchmark-run 函数,它可以按照需求多次调用我们的函数,并输出调用结果。它的签名如下:
emacs-lisp
(benchmark-run &optional REPETITIONS &rest FORMS)
REPETITIONS 是一个数字表示应该运行后面的 FORMS 多少次。输出的结果是一个形如 (总耗时(秒) GC执行次数 GC总耗时(秒)) 的列表
我们可以分别执行下面的测试代码
emacs-lisp
(benchmark-run 1 (my-inefficient-string-join 1000))
(benchmark-run 1 (my-inefficient-string-join 2000))
(benchmark-run 1 (my-inefficient-string-join 4000))
结果如下:
txt
(0.057176 2 0.05415199999999931)
(0.159746 8 0.15689200000000003)
(0.649431 36 0.6428240000000001)
我们发现主要开销在GC上,调用次数是2倍增长,但是GC次数大概是 4倍增长符合我们上面提到的 O(n²) 的时间复杂度。所以对于这种情况我们不应该使用 concat 来连接。
其实对于这种需要频繁插入新元素的场景,正确的做法是使用 list 作为基础数据结构。我们的上述代码可以改为:
emacs-lisp
(defun my-efficient-string-join (n)
(let ((lst '()))
(dotimes (i n)
(push (number-to-string i) lst))
(string-join (nreverse lst) "")))
最终再次执行 benchmark-run 得到的结果如下:
emacs-lisp
(0.032276 1 0.031180999999999237)
(0.001953 0 0.0)
(0.028748 1 0.02522500000000072)
我们发现它的开销不再随着循环次数的上升而显著上升了。
总结
本篇我们通过一个例子,讨论了进行性能分析的一般方法。在此对上述方法进行总结:
可以通过 profiler 机制分析某个操作具体卡在哪个函数, 配套的函数有:profiler-start、profiler-report、profiler-stop。
然后通过 elp 系列统计函数的调用次数和消耗情况,对应的函数是: elp-instrument-list、elp-results、elp-restore-function 和 elp-restore-all
最后针对具体函数可以使用 benchmark-run 单独多次执行表达式,获取性能信息