はじめに

Emacsの起動高速化については、とても定評がある記事がある。

これを参考にすれば誰でも起動速度を"詰める"ことはできるはずで、あまり俺ごときが追加で書くような 話はないのだが、自分なりのメモを残しておこうと思う。

leaf.elとpackage.elでの起動高速化を目指す

本件はleafでパッケージ設定を書き、パッケージ管理をpackage.elにまかせつつ高速化する話で、 実のところ最速を狙う手法ではない。まあ最速じゃなくてきっと意味はあるよね……ということで。

遅延初期化

通常Emacsでの遅延初期化は、他の.elを読んだら評価する(with-eval-after-load)や、 書かれた関数・変数が使われたら呼ぶ(autoload)に頼ることになるのだが、 package.elを使いつつemacs-init-timeを100ms未満にしようとするなら、もう一歩踏み込む必要がある。

遅延実行

まず、Emacsを起動した後、あまり使わないパッケージの初期化が全部終わってなくてもエディットは 始めていいはずだ。よって、よくある例と同様にEmacs起動後に遅延評価させるようにする。

;; after-init-hookで順次登録された関数を実行する
(defvar my/delayed-configs nil)
(defvar my/delayed-config-timer nil)
(defvar my/delayed-config-done nil)

(eval-and-compile
  (defconst my/prio-low    1)
  (defconst my/prio-normal 10)
  (defconst my/prio-urgent 100))

(defun my/add-to-delayed-configs (priority config)
  "Add CONFIG with PRIORITY to delayed configs."
  (if my/delayed-config-done
      (condition-case err
          (eval config)
        (error (message "my/delayed-config exection error: %s" err)))
    (push (cons priority config) my/delayed-configs)
    ;; sort the configs by priority
    (setq my/delayed-configs (sort my/delayed-configs (lambda (a b) (> (car a) (car b)))))))

(defun my/execute-config (config)
  "Execute a single delayed CONFIG safely."
  (let ((inhibit-message t))
    (condition-case err
        (eval config)
      (error (message "my/delayed-config execution error: %s" err)))))

(defun my/execute-delayed-configs ()
  "Execute delayed configs using timer for urgent priority and idle timer for others."
  (if my/delayed-configs
      (let* ((config-pair (pop my/delayed-configs))
             (priority (car config-pair))
             (config (cdr config-pair)))
        (if (>= priority my/prio-urgent)
            ;; For urgent priority, use run-with-timer
            (run-with-timer 0.1 nil
                            (lambda ()
                              (my/execute-config config)
                              (my/execute-delayed-configs)))
          ;; For normal and low priority, use run-with-idle-timer
          (run-with-idle-timer
           (if (>= priority my/prio-normal) 0.5 1.0) nil
           (lambda ()
             (my/execute-config config)
             (my/execute-delayed-configs)))))
    (setq my/delayed-config-done t)))

(add-hook 'after-init-hook 'my/execute-delayed-configs)

(defmacro with-delayed-startup-exec (priority &rest body)
  "Execute BODY after init with delay, according to PRIORITY."
  (declare (indent 1))
  `(my/add-to-delayed-configs ,priority ',(cons 'progn body)))

priorityをつけたので先例より実装がちょっとだけ大きくなったが、見ての通り大した事はしてない。

これでinit.elに↓みたいに書くと、Emacsの初期化が終わったあと、プライオリティ(my/prio-urgent, my/prio-normal, my/prio-low)の順に従って遅延実行される。

urgentはrun-with-timerで問答無用で実行し、normalとlowはrun-with-idle-timerでidleなら実行する形に してみた。

(with-delayed-startup-exec my/prio-normal
    ;; ほげほげ
)

遅延評価とleafの同居

上記のwith-delayed-startup-execはleafの:initに書くことになる。

本来init.elを実行した際に終わっているべき設定項目も遅延実行されることになるので、つまりは:initが実行済みであることが前提になる他のleafの機能を使うと、うまくない事が起こる場合がある。

たとえば:bind。leafは:bindで関数をキーに割り当てると、その関数に対するautoloadが書かれる。初期設定を遅延実行しているため、初期設定が実行される前にキーを押てしまうと、ロード前に行われてないとまずい設定が行われずに、望ましい実行結果にならない事がある。

どうするかというと私は:bindも:hookを封印しして、:initに書いたwith-delayed-startup-execの中で明示的に設定することにした。:bindはleaf-keysで置きかえると書式が同じで楽ができる。:hookはちまちまadd-hookする。

(leaf bm
  :ensure t
  :init
  (with-delayed-startup-exec my/prio-low
    (require 'bm)
    (leaf-keys (("<C-f2>" . bm-toggle)
		("<f2>" . bm-next)
		("<S-f2>" . bm-previous)))
    (add-hook 'find-file-hook 'bm-buffer-restore)
    (add-hook 'after-revert-hook 'bm-buffer-restore)
    (add-hook 'kill-buffer-hook 'bm-buffer-save)
    (add-hook 'after-save-hook 'bm-buffer-save)
    (add-hook 'kill-emacs-hook 'bm-repository-save)
    (add-hook 'kill-emacs-hook 'bm-buffer-save-all)
    (bm-repository-load)))

こんな感じ。add-hookはリストを使って設定してもいいけど、この程度の行数なら列挙の方が早くていいだろう。Emacsの起動時間を""詰める""のコンパイル時ループアンローリングをしてもいいかもしれない。

package.elを読み込みたくない話

最近のEmacsはpackage-quickstartがtなら、package-quickstart.el(c)が事前に実行されることによって、init.el内ではpackage.elが読みこまれない状態でpackage関連のautoloadが全部設定されている状況になっている。

しかし、leafで:ensure tとか:package tとか書くと、autoload cookieが書いてある関数package-installed-pが呼ばれるから。package.elが読みこまれてしまう。package.elを読み込ませてしまうと、大物のbrouse-urlとか読まれてしまうのでemacs-init-timeが伸びる。うちのRyzen 9 7950X3D上のWSLの環境だと50msほどで、無視できない。

でもleaf使ってるんだし、:ensure tと書きたいやん……というわけでleaf-handler-packageをinit.elにコピペして再定義しようかと思ったけど、adviceでなんとかなる気がしたので、そうしてみた。

  (defun my/package-installed-p (package)
    "Check if PACKAGE is installed.
First checks if PACKAGE is in `package-activated-list`.
If not, uses the original `package-installed-p`."
    (or (and (boundp 'package-activated-list)
             (or (member package package-activated-list)  ; package-quickstart
		 (assq package package--builtin-versions))) ; builtin packages
	(package-installed-p package)))

  (advice-add 'leaf-handler-package :around
	      (lambda (orig-fun name pkg pin)
		(let ((result (funcall orig-fun name pkg pin)))
		  (cl-subst 'my/package-installed-p 'package-installed-p result))))

my/package-install-pは見ればわかる通り、package-quickstart.el(c)で設定されるpackage-activated-listを使ってpackageのinstall状況を簡単に確認する。荒い判定だがnilになったらモノホンのpackage-installed-pを呼ぶのでエッジケース以外では事故らないはず。

(2024/10/29追記)ビルトインのpackageの検出に失敗してpackage-installed-pを呼んでしまっていたので、package–builtin-versionsもチェックするようにした。

同様にleafのインストールコードも変えてpackage.elはなるべく読み込まないようににする。 package-initializeも普通は呼ぶ必要がない1ので、そうする。

  (with-eval-after-load 'package
    (add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/")))
  (setq package-quickstart (file-readable-p package-quickstart-file))
  (unless package-enable-at-startup
    (package-initialize))
  (unless (my/package-installed-p 'leaf)
    (package-refresh-contents)
    (package-install 'leaf))

起動速度観点で、WSL上のemacsで注意すべきこと

WSLは何もしないとPATHにwindows側のPATHも追加する。 Windows側の実行ファイルは拡張子exeつきなので、Linux側と混じらず便利に使えるのだが、 Windows側のファイルアクセスが遅いために、コマントの不存在チェックがクソ遅くなる。

べつにいいやん?と思いそうだが、emacsのパッケージには実行ファイルの存在有無を確認するものがある。 例えば前述のbrowse-urlがまさにそうだ。こんなdefcustomがいっぱいある。

  (defcustom browse-url-chrome-program
    (browse-url--find-executable '("google-chrome-stable" "google-chrome")
				 "chromium")
    "The name by which to invoke the Chrome browser."
    :type 'string
    :version "25.1")

Windows側のPATHの量にも依存するが、見付からないケースにおいて1コ数十msを消費する。 空振りするのがわかっているなら、事前に明示的に指定しておくほうがいい。Windows側のPATHを足さない設定にしてもいいかもしれないが、WSLの利点の一つはWindows側とのinteroperabitilyだし、工夫で乗りきりたい。

  (setq browse-url-chrome-program "chrome"
	browse-url-chromium-program "chromium"
	browse-url-firefox-program "firefox")

あとは、自分のinit.el内でexecutable-findを利用して条件分けをするなら、byte-compile時に結果を確定させるとruntimeでは時間を食わなくなる。

  (defmacro !executable-find (command)
    "Execute \='executable-find\=' COMMAND at the byte-compile time."
    `(eval-when-compile
       (executable-find ,command)))

今はもう使っていないので覚えていないが、el-getにも似たような罠があったと記憶している。2

以上の工夫で

WSL上の3400行ほどのinit.elだが、emacs -nwで起動するならemacs-init-timeは70〜80msくらいになった。キャッシュを捨てたあとだと160msくらい食うのでまだまだである……。

(EOF)


1

early-init.elでpackage-enable-at-startupがnilにされている時だけ呼ぶ必要があったはず

2

el-get-finkとかel-get-brewとかをnilにしていた記憶がある。