关于load-path增多对emacs运行速度的重大影响

结论

load-path数量增多不但影响emacs启动,还影响运行, 
两个比较典型的场景
1 启动缓慢
2 即便用了lazyload, 但是打开emacs后,第一次执行某个操作会明显感觉卡顿, 但是第二次执行这个操作就流畅了

之前论坛有老哥提到这个问题

并提到了解决方案

1. (setq load-suffixes '(".elc" ".el")) ;; to avoid searching .so/.dll
2. (setq load-file-rep-suffixes '(""))  ;; to avoid searching *.gz
3. Use the load-hints in https://mail.gnu.org/archive/html/bug-gnu-emacs/2024-10/msg00905.html
4. Combin the packages into one directory.

但其实这几项解决的都是同一个问题, 即下图

从图中可以看到china-util.elC:\msys64\mingw64\share\emacs\30.2\lisp\language\china-util.el
但是由于他所在的路径在load-path的很后面,所以进行了非常多的无效打开文件的操作


上面提到的前两个优化方案都是排除后缀, 提升速度, 但是没有解决load-path很大的时候,仍然会在很多错误的目录里去找的问题,特别是load-path的目录越多, 执行越慢.

里面提到了load-hints 用来解决这个问题. 但是我搜索了emacs源码,并没有找到相关的说明,在spacemacs的FAQ里面提到有load-hints优化, 我尝试了启用, 并没有效果 ,仍然会出现前面sysmon中的情况 Windows如何流畅运行Emacs? - #21,来自 junmoxiao
Frequently Asked Questions

翻emacs提交, 貌似最终变成了这个commit?

但是有几个问题
1 我在 kiennq 的预编译版本里面C-h f C-h v都看了下, 没有看到相关的符号
2 他貌似也没有解决这个问题, 只是过滤了下目录必须带有el elc后缀

大家有什么想法吗?

4 个赞

也许可以把所有包手工加到emacs源码里,然后dump成一个emacs image,这样emacs在启动的时候就不需要加载任何包了。理论上这是可行的……但可能没有这里说的那么简单。

你说的可能是pdump? 但是我之前试了好多次, 都会在某个包遇到问题, 启动不起来, 后来貌似也没人讨论这个特性了

我感觉抛弃load-path, 把所有的包都指定路径用load显式加载应该可以解决这个问题

或者说改emacs源码, 每次load之后就把当前传的路径参数和真正路径的对应关系缓存下来,下一次load之前先检查是否存在,如果存在就直接加载,否则才去load-path找

是的。我没有试过,也许有人成功过?

autoload

关于减少启动时的文件访问,我之前发过一篇相关的贴子,可能有所帮助。

貌似有点不一样, 你仔细看看上面sysmon的截图呢, 很多系统的包也是后面才加载的, 我估计在windows上find-file第一次执行会比后面卡顿的原因就在这里, 通过autoload应该解决不了这个问题

一些内置包里面require套的太多,导致加载引起卡顿。如果把require换成autoload的话,性能会提升的。我不认为搜索目录会造成太大的开销,而且搜索目录也并不需要打开文件,这里CreateFile显然是把这些包一次性给加载了。如果想要在不修改包的前提下提升性能,就只能采用预加载的方式。

一个可能的办法是把emacs和你需要的包完全加载到内存中,后用某个第三方工具dump下来,需要启动的时候把image热加载到内存中。加载所有包是做不到的,因为有一些内置包根本不能在所有平台工作,或者有潜在的内部冲突。

这里有一个列表,是我之前在emacs 29上尝试加载所有内置包的时候发现的有问题的包:

(defvar library-blacklist
  '(;; macroexpansion exceed
    "/usr/share/emacs/30.1/lisp/emacs-lisp/cconv.el.gz"
    ;; macroexpansion exceed
    "/usr/share/emacs/30.1/lisp/emacs-lisp/macroexp.el.gz"
    ;; depends on specific network env
    "/usr/share/emacs/30.1/lisp/net/eudc-bob.el.gz"
    ;; should not be loaded twice
    "/usr/share/emacs/30.1/lisp/international/characters.el.gz"
    ;; emacs will crash when loading this
    "/usr/share/emacs/30.1/lisp/erc/erc.el.gz"
    ;; BUG: failed to search `latin1-display-ucs-per-lynx'
    "/usr/share/emacs/30.1/lisp/international/latin1-disp.el.gz"
    ;; BUG: failed to search `comp--add-cond-cstrs'
    "/usr/share/emacs/30.1/lisp/emacs-lisp/comp.el.gz"
    )
  "Library paths that will not be loaded.

Some Emacs libraries contains malformed `cl-defmacro' which may cause
stackoverflow or other dangerous forms.  We had better skip them before
get prepared for them.")

我之前参考过chenbin的配置,他就是手动指定了autoload , https://github.com/redguardtoo/emacs.d/blob/master/lisp/init-autoload.el

windows上启动速度1.7s左右,差不多到极限了,
按照我上面说的设置load-suffix后缀就从1.7s降低到1.1s.
说明这个加载路径的问题确实对启动速度有明显影响

我的意思是把内置包里面所有的require换成autoload,只在init.el文件里指定对内置包里的require关系是没有用的。或者更直接的,不考虑延迟加载,直接把它们都换成(load <绝对路径>),也有一样的效果。改load是改不掉这些内置包里的require的。只要require的包没有加载,就会触发搜索load-path。

基于最新的emacs commit 改了个load函数

用法就是先正常使用, 然后在 scratch里面C-x C-e 调用 (load-write-cache-file)
就会自动在.emacs.d下面创建一个 .load-cache 里面包含了缓存(现在还比较呆,不会覆盖内存缓存的值,如果native-comp编译完了,需要先关闭emacs,删除缓存文件, 重新打开emacs后再调用写缓存函数)

下次启动的时候emacs就会先从这个文件里面读取缓存

我实际测试了下,使用chenbin的配置,init.el开头加上后缀设置.

(setq load-suffixes '(".eln" ".elc" ".el"))
(setq load-file-rep-suffixes '(""))

对比了三个版本,
1 官方的emacs30.2
2 kiennq emacs-bc56c10-ucrt-x86_64-mps最新 mps release
3 自己编译的

启动速度 emacs30.2和自己编译的都是1.2秒左右, kiennq是1.87秒
第一次使用某个功能时卡顿的问题也得到解决, 比如find-file 或者启动eglot时候
find-file速度对比 自己编译的是0.07秒左右 ,emacs30.2和kiennq版本是0.4秒左右
(之前测试的时候发现奇怪的现象,就是kiennq版本打开文件的速度也提升了,后来发现是因为他会预加载recentf里面的文件, 测试之前都把recent记录删掉能得到第一次调用find-file打开一个新文件需要的真实时间)

测试代码

(let ((time (current-time)))
  (find-file "C:\\temp\\1.txt")
  (message "%.06f" (float-time (time-since time))))

@include-yy

3 个赞

:light_blue_heart:

不知道和之前主线的 load cache 实现有什么区别,明天仔细看看。

不过我感觉这个实现还是不大行, 可能更好的解决方案是弄个字典, 提前把load-path里面的所有文件按照次序建立好索引, load的时候直接从索引里面去读真正的路径, 而不是遍历CreateFile. 一个一个的尝试CreateFile也太笨了

1 个赞

load-path-filter-function实现代码如下:

(defvar load-path-filter--cache nil
  "A cache used by `load-path-filter-cache-directory-files'.

The value is an alist.  The car of each entry is a list of load suffixes,
such as returned by `get-load-suffixes'.  The cdr of each entry is a
cons whose car is a regex matching those suffixes
at the end of a string, and whose cdr is a hash-table mapping directories
to files in those directories which end with one of the suffixes.
These can also be nil, in which case no filtering will happen.
The files named in the hash-table can be of any kind,
including subdirectories.
The hash-table uses `equal' as its key comparison function.")

如果在 init.el 中添加了 (setq load-path-filter-function #'load-path-filter-cache-directory-files) ,这个 cache 变量会存放加载过程中的一些缓存信息,它是一个 alist,其中每个条目的 car 是一个加载后缀列表,cdr 是一个 cons,其中 car 是匹配这些后缀的正则,cdr 是哈希表,哈希表的键是路径,值是目录中的所有满足后缀正则的文件。我截取了我的 load-path-filter–cache 的某个条目的部分内容:

((".dll" ".elc" ".elc.gz" ".el" ".el.gz")
 "\\(?:\\.\\(?:dll\\|el\\(?:\\.gz\\|c\\(?:\\.gz\\)?\\)?\\)\\)\\'" .
 #s(hash-table test equal data
               ("d:/_D/msys64/home/26633/.emacs.d/elpa/activities-0.7.2" ("activities.elc" "activities.el" "activities-tabs.elc" "activities-tabs.el" "activities-pkg.el" "activities-list.elc" "activities-list.el" "activities-autoloads.el")
                "d:/_D/msys64/home/26633/.emacs.d/elpa/asmd-0.1" ("asmd.elc" "asmd.el" "asmd-pkg.el" "asmd-autoloads.el")
                "d:/_D/msys64/home/26633/.emacs.d/elpa/bison-mode-20210527.717" ("bison-mode.elc" "bison-mode.el" "bison-mode-pkg.el" "bison-mode-autoloads.el"))))
(defun load-path-filter-cache-directory-files (path file suffixes)
  "Filter PATH to leave only directories which might contain FILE with SUFFIXES.

PATH should be a list of directories such as `load-path'.
Returns a copy of PATH with any directories that cannot contain FILE
with SUFFIXES removed from it.
Doesn't filter PATH if FILE is an absolute file name or if FILE is
a relative file name with leading directories.

Caches contents of directories in `load-path-filter--cache'.

This function is called from `load' via `load-path-filter-function'."
  (if (file-name-directory file)
      ;; FILE has more than one component, don't bother filtering.
      path
    (pcase-let
        ((`(,rx . ,ht)
          (with-memoization (alist-get suffixes load-path-filter--cache
                                       nil nil #'equal)
            (if (member "" suffixes)
                '(nil ;; Optimize the filtering.
                  ;; Don't bother filtering if "" is among the suffixes.
                  ;; It's a much less common use-case and it would use
                  ;; more memory to keep the corresponding info.
                  . nil)
              (cons (concat (regexp-opt suffixes) "\\'")
                    (make-hash-table :test #'equal))))))
      (if (null ht)
          path
        (let ((completion-regexp-list nil))
          (seq-filter
           (lambda (dir)
             (when (file-directory-p dir)
               (try-completion
                file
                (with-memoization (gethash dir ht)
                  (directory-files dir nil rx t)))))
           path))))))

首先,如果文件是个路径就不做过滤,如果是文件则使用 (alist-get suffixes load-path-filter--cache nil nil #'equal) 尝试从 cache 中找到符合后缀的条目,pcase-let 中的 ht 会匹配上面我们提到的哈希表。

在获取哈希表后,接下来是通过 seq-filter 过滤 path 列表(类似 load-path 内容的列表),具体来说就是通过 try-completion 判断 file 是否在某一目录下的文件列表中。文件列表的获取逻辑是:如果在 with-memoization 中找到了该路径上则返回路径对应的文件列表,否则执行 directory-files 找到目录下文件的文件名并缓存在哈希表中。

经过这样的过滤操作,可以大大减少 load 过程中需要遍历的文件目录数量。相比原先可能遍历每个 load-path 中的目录,先过滤再在比较小的子集中查找要快不少,因为过滤操作不涉及系统调用。

lread.c 中,这一补丁也修改了 load 的定义(e5218df),添加了如下内容:

Lisp_Object load_path = Vload_path;
if (FUNCTIONP (Vload_path_filter_function))
  load_path = calln (Vload_path_filter_function, load_path, file, suffixes);

当第一次调用 load 时,根据上面的实现逻辑,它就会将 path 中的所有路径做缓存处理,之后的 load 能够根据这一信息过滤到尽可能多的路径来实现快速加载。关于这一实现的讨论在 2025 年的 2 月, 4 月和 5 月: Speeding up loading when load-path has many packages

顺带一提,我的 load-path 长度为 130,mapc 一遍用时差不多是 8ms。

(length load-path)
130
(benchmark-run (mapc (lambda (x) (directory-files x nil)) load-path))
(0.008635 0 0.0)
3 个赞

看了下,好像是直接从加载符号对应到绝对路径?感觉可能比 31.0.50 的每次运行时创建加模糊匹配快,等会编个试试。

提升挺大的样子。下面是测试过程。我没有使用 native-comp ./configure --without-pop --without-compress-install --prefix=/d/emacs-build --with-native-compilation=no

  1. 首先在我的默认配置下启动,启动时间在 4.1s~4.3s 之间波动
  2. 使用 load-path-filter-function,启动时间在 3.6s~3.65s 左右
  3. 不使用 load-path-filter-function,启动 Emacs 后使用 (load-write-cache-file),随后启动时间在 2.8s~3.0s 之间
  4. 不使用 load-path-filter-function,启动 Emacs 后使用 (load-write-cache-file),设置 load-suffixesload-file-rep-suffixes ,也差不多在 3.0s 附近。

感觉可以 hack 一下 Emacs 的那个实现看看能不能用上带持久缓存的版本。

1 个赞

其实启动时间在1s左右能接受了, 主要是第一次执行某个功能时,emacs才会去找对应的文件load进来(可以用procmon看,我前面说错了, 不是sysmon),
就我前面提到的, 启动后第一次执行find-file明显有差距(或者其他功能,比如magit eglot, 涉及到的文件越多的包加载时间的差距更明显, magit第一次执行会卡好几秒)

启动后第一次执行find-file速度对比 
`自己编译的是0.07秒左右 ,emacs30.2和kiennq版本是0.4秒左右 `

补充一句: 其实从procmon的记录看, windows上的emacs还有很多优化的空间,比如emacs会在home目录下去找很多遍.emacs , 还会遍历PATH找ssh.exe, 很多都没必要

考虑到这是直接跳过了查找的过程,有这个提升倒也正常,不过提升确实挺大的。有时间我照你这个思路重写一个带 cache 持久化的 load-path-filter-cache-directory-files 试试。

3 个赞

可以看我自己写的一个函数,比Emacs内置的函数,更容易自定义过滤一些不必要的大目录

(defun add-subdirs-to-load-path (search-dir)
  (interactive)
  (let* ((dir (file-name-as-directory search-dir)))
    (dolist (subdir
             ;; 过滤出不必要的目录,提升Emacs启动速度
             (cl-remove-if
              #'(lambda (subdir)
                  (or
                   ;; 不是目录的文件都移除
                   (not (file-directory-p (concat dir subdir)))
                   ;; 父目录、 语言相关和版本控制目录都移除
                   (member subdir '("." ".." 
                                    "dist" "node_modules" "__pycache__" 
                                    "RCS" "CVS" "rcs" "cvs" ".git" ".github")))) 
              (directory-files dir)))
      (let ((subdir-path (concat dir (file-name-as-directory subdir))))
        ;; 目录下有 .el .so .dll 文件的路径才添加到 `load-path' 中,提升Emacs启动速度
        (when (cl-some #'(lambda (subdir-file)
                           (and (file-regular-p (concat subdir-path subdir-file))
                                ;; .so .dll 文件指非Elisp语言编写的Emacs动态库
                                (member (file-name-extension subdir-file) '("el" "so" "dll"))))
                       (directory-files subdir-path))

          ;; 注意:`add-to-list' 函数的第三个参数必须为 t ,表示加到列表末尾
          ;; 这样Emacs会从父目录到子目录的顺序搜索Elisp插件,顺序反过来会导致Emacs无法正常启动
          (add-to-list 'load-path subdir-path t))

        ;; 继续递归搜索子目录
        (add-subdirs-to-load-path subdir-path)))))

(add-subdirs-to-load-path "/usr/share/emacs/lazycat")
5 个赞

是的。我去查了下cygwin的代码,发现这个stat()居然是先open()判断能不能打开。cygwin的stat()的成本过于高昂,所以才会出现一堆CreateFile调用。

1 个赞

这个函数效果和设置后缀的差不多吧, 今天又看了下magit的启动过程
就只执行一个 magit-status. procmon里面就记录了五万个操作
其中大部分都是在错误的目录(load-path里面有两百多个包的路径, 这些路径都包含el文件)里面去找magit的el文件

但是我写了个半自动的, 发现没效果, 用的emacs 版本是最新commit编译的(没用我上面的补丁)


(defun my-load-path-cache-load ()
  "Load `load-path-filter--cache' from ~/.emacs.d/.load-path-cache."
  (interactive)
  (let ((cache-file (expand-file-name ".load-path-cache" user-emacs-directory)))
    (when (file-exists-p cache-file)
      (with-temp-buffer
        (insert-file-contents cache-file)
        (setq load-path-filter--cache (read (current-buffer))))
      (message "load-path cache loaded from %s" cache-file))))


(defun my-load-path-cache-save ()
  "Write `load-path-filter--cache' to ~/.emacs.d/.load-path-cache."
  (interactive)
  (let ((cache-file (expand-file-name ".load-path-cache" user-emacs-directory)))
    (with-temp-file cache-file
      (prin1 load-path-filter--cache (current-buffer)))
    (message "load-path cache saved to %s" cache-file)))

执行magit-status之后, 看load-path-filter–cache里面的值,已经有magit的

执行 my-load-path-cache-save 把内容保存到文件, 重启后执行 my-load-path-cache-load 把值设置回来.
然后再次执行magit-status,用procmon看,发现仍然会去错误的目录找magit相关的el文件加载

我在early-init.el里面设置了

(when (boundp 'load-path-filter-function)
  (setq load-path-filter-function #'load-path-filter-cache-directory-files))

(setq load-suffixes '(".elc" ".el"))
(setq load-file-rep-suffixes '(""))