Edited at
EmacsDay 9

postpone.el で起動と拡張読み込みを分離する


はじめに

この記事では「Emacsの起動は遅い」なる飛び石から身を護る一つの手法を紹介します。

エディタの逐次起動です。

以下の読者を想定しています。


  • 起動時間が長いと不安になる

  • 高い頻度で Emacs を起動・終了する傾向がある

  • 「起動の高速化?emacsclientやろ?」とは考えていない(設定するの面倒)


  • init.el の記述を use-pacakge.el で構造化していない(書き直すのが面倒)


  • use-package.el の読み込み自体がオーバーヘッドだと感じている(ニッチ)

なお私は主に macOS で作業し、ほとんどのシーンで GUI の Emacs を使います。ただ、Gitでコミットメッセージを書く時や、コンソールで作業する時は CUI で Emacs を使います。alias e='emacs' です。


Emacs の起動とは何か

エディタを起動するとは何でしょうか?

普通に考えれば「任意のファイルを所定の設定の下で編集可能な状態にする」が一般的な答えでしょう。しかし、この定義には2つの要素が含まれています。それは、


  1. アプリケーション(Emacs)の起動

  2. 関連する拡張の読み込みとセットアップ

です。エディタを使い込めば、それに伴って、必要な拡張も増えていくのが世の常です。すると、拡張の読み込みとセットアップの量が必然的に肥大化し、結果としてエディタの起動が遅くなります。

しかしそれは、起動という瞬間に「様々な処理が集中する」のが主な要因です。ユーザは起動処理を待つ間にエディタと対話ができず「遅い!」と感じるわけです。

そうであるならば、とりあえず「Emacsの起動」だけを先に終わらせてしまいましょう。そして、必要な拡張の読み込みとセットアップは、それらが本当に必要な時に初めて実行しましょう。逐次起動するのです。うまく行けば、エディタの起動で待たされるストレスを大幅に低減できるはずです。(図中の%は参考例)

Untitled.png

なお、この概念で考えると、 after-init-hook に色々と追加するのは誤りです。起動時に作業が集中することになります。


一般的な高速化方法

逐次起動を目指す以前に、試しておくべき一般的な起動の高速化方法があります。emacsclient を使うのはスコープ外です。それと、SSDを使ってください


use-package.el を使う

use-package.el を使って init.el を書き直してください。すぐに幸せになれます。ただし、既存の設定をすべて書き換えるのはかなりのコストです。また、use-package.el の読み込みが前提となるため、高速化に限界があります。私の環境では、use-package.elの読み込みに約70[ms]も必要で、起動時間を約100[ms]程度に抑えたい私にとっては、オーバーヘッドでしかありません。

use-package.el: A use-package declaration for simplifying your .emacs


設定上の工夫


起動中にガベージコレクタを動かさない

起動時に段階的なメモリ確保が生じると遅くなります。それを防ぐために、起動に必要なメモリ量を予め指定しておきます。

(setq gc-cons-threshold 134217728) ;; 128MB 注) 環境によります。

(setq garbage-collection-messages t) ;; GC発生時にメッセージを出す


パッケージ読み込みの記述法を変える

require の使用を徹底的に諦めます。package.elで拡張をインストールしている場合は、次のように設定を書き換えるべきです。

;; ダメな設定

(when (require 'hoge nil t)
;; 設定群を記述
)

;; 良い設定
(with-eval-after-load "hoge"
;; 設定群を記述
)


ロードパスをむやみに増やさない

load-path 変数の中身を確認してみてください。この変数が肥大化している場合は、拡張ファイル群を一つのディレクトリにまとめる工夫が必要です。私の場合は、すべてのパッケージに対するエイリアスを作成し、それらを一つのディレクトリにまとめて配置し、そのディレクトリだけを load-path に追加しています。I/Oが遅い環境ではかなりの効果が得られるはずです。


postpone.el を導入する

本題です。

postpone.el を使えば、任意の設定の実行を起動後のユーザアクションまで先送りできます。use-package.el で設定を書き換えるのは面倒だが、とりあえず起動だけは早くしたいユーザには特にオススメです。(図中の%は参考例)

postpone.el: Call functions just one time at your first action in Emacs

Untitled2.png


インストール

GitHubからダウンロードして load-path に配置してください。MELPA登録は検討中です。Cask利用者の場合は、

(depends-on "postpone"     :git "https://github.com/takaxp/postpone.git")

でインストールできます。


設定

複雑なことはありません。以下のデフォルトコードを init.el にコピペして、読み込みを先送りしたい設定群を (with-eval-after-load "postpone") で括るだけです。起動の直後に必要とならない設定を、すべて postpone でくくってください。突き詰めていけば、フレームの装飾、フォント設定、テーマ適用等の極めて基礎的な設定以外は、すべて postpone で括れるはずです。


デフォルトコードのコピペ

次のコードを init.el の前の方にコピペしてください。postpone.elload-path に含まれていることを前提にします。

(if (not (locate-library "postpone"))

(message "postpone.el is NOT installed.")
(autoload 'postpone-kicker "postpone" nil t)
(defun my-postpone-kicker ()
(interactive)
(unless (memq this-command ;; specify commands for exclusion
'(self-insert-command
save-buffers-kill-terminal
exit-minibuffer))
(message "Activating postponed packages...")
(let ((t1 (current-time)))
(postpone-kicker 'my-postpone-kicker)
(setq postpone-init-time (float-time
(time-subtract (current-time) t1))))
(message "Activating postponed packages...done")))
(add-hook 'pre-command-hook #'my-postpone-kicker))


先送りしたい設定群を分離する

例えば、 (when (require 'hoge nil t) ...) と記述している部分が重い処理ならば、次のように (with-eval-after-load "postpone") で括るだけです。

;; 元々の設定

(when (require 'hoge nil t)
;; たくさんの設定
)

;; 新しい設定
(with-eval-after-load "postpone"
(when (require 'hoge nil t)
;; たくさんの設定
))

こうすることで、Emacs の起動時には hoge.el の読み込みとセットアップがスルーされ、起動後の最初のユーザアクション(カーソル移動など)が生じる時まで先送りされます。


先送りした設定の読み込みにかかる時間を調べる

M-x postpone-init-time を実行すると、M-x emacs-init-time と同じように、ミニバッファに読み込み時間が表示されます。


設定のカスタマイズ

前出のデフォルトコードのうち、

'(self-insert-command save-buffers-kill-terminal exit-minibuffer)

としてある部分は、必要に応じて変更してください。ここに追加されたコマンドは、先送り対象の設定を読み込むための発動コマンドから除外されます。上記の3つのコマンドは「CUIで起動し、Gitのコメントを書き、Emacsを終了する」という一連の操作を邪魔しないように意図されたもので、その間、postpone.elに紐づけたパッケージ群は読み込まれないので、待たされることがありません。


効果の確認

手元の init.el は、約4800行。Caskに登録している拡張の数は、約180パッケージです。180ものパッケージをもし起動時に一斉に読み込んだら何が起こるのか、容易に想像できると思います。


postpone で分離している設定の数

全部で84項目が init.el の中で先送り指定されています。依存するパッケージも含めて、合計で約400[ms]に相当する設定の読み込みを先送りしています。

私の init.el では、注意深く遅延設定を施しているためこの程度の時間ですが、 (when (require 'hoge nil t)) を多用している場合は、秒単位の劇的な効果が得られます。


起動に要する時間

「起動時間」と「postpone.elで先送りした時間」を比較します。

以下の表は、GUIとCUIのそれぞれの場合の起動時間です。起動時間の数値には、postpone.el で先送りされたパッケージの読み込み時間が含まれません。先送りしたパッケージの読み込み時間は、M-x postpone-init-time でわかります。

GUI[ms]
CUI[ms]

起動時間
493
169

postpone.elで先送り
372
424

なお、いわゆる emacs -q で起動する時の M-x emacs-init-time は、

GUI[s]
CUI[s]

起動時間(emacs -q)
0.3
0.0

です。単位の違いに留意してください。上の2つの表を比較すると、postpone.elによる逐次起動では、無設定の起動に対して、100-200[ms]のオーバーヘッドで高速起動できていることがわかります。postpone.elで先送りをしなければ、さらに350-450[ms]の待ち時間が必要になります。

NOTE-- emacs-init-time に 次の advice を適用しただけの状態で Emacs を起動すると、emacs -q相当の起動は、GUIにおいて、0.349[s]程度だとわかりました。CUIの場合は、0.043[s]程度でした。


ad-emacs-init-time

(defun ad:emacs-init-time ()

"Return a string giving the duration of the Emacs initialization."
(interactive)
(let ((str
(format "%.3f seconds"
(float-time
(time-subtract after-init-time before-init-time)))))
(if (called-interactively-p 'interactive)
(message "%s" str)
str)))
(advice-add 'emacs-init-time :override #'ad:emacs-init-time)

参考までに、postpone.elで先送りした設定を読み込んだ状態で「init.elを開く」と「Org Mode を有効にして org file を開く」の操作をそれぞれ独立に実行して処理時間を調べてみました。

GUI[ms]
CUI[ms]

init.el を開く
949
1020

org file を開く
5255
5847

Org Modeの設定に5秒以上も要しているのは、Org Modeの読み込みに紐づけているパッケージが多数あるためです。その上位20位を見てみると、次の表のようになります。明らかに不要な設定が読み込まれていることがわかります。例えば、emmsorg-emmshelm-emms は音楽を聞く時にだけ読み込めばいいので、必ずしも org file の表示タイミングで必要というわけではありません。すなわち、単純な設定の見直しで約1.2秒、高速に org file を開けるようになります。

読み込み時間[ms]

org-emms
547

emms
532

ox-org
236

ox
234

org-gnus
181

gnus-sum
173

(loading)org-loaddefs.el
153

helm-emms
132

helm
126

ob-emacs-lisp
114

gnus-group
114

ob-core
112

gnus-start
103

helm-config
98

ispell
98

ox-hugo
83

ffap
72

gnus-int
66

ob-C
60

cc-mode
58


最適化例

上記のように、init.el の設定には最適化の余地があったわけなので、最適化してみます。

結果的に、orgファイルの表示は、GUIにおいて、5255[ms]から1841[ms]まで短縮できました。もし、先行して helm を起動していたならば、orgファイルのオープンには 1276[ms]で済むようになりました。ほぼストレスフリーです。


  • emms 関連を除外

以下の設定が、org に紐付けられていたので、(with-eval-after-load "emms") 以下に移しました。これで、1487[ms]削減です。org-emmsの読み込みは、emmsを使う時まで先送りされます。

  (unless noninteractive

(require 'org-emms nil t))


  • ox- モジュールを隔離

以下の設定のように、ox-系モジュールの読み込みを (with-eval-after-load "ox") 以下に移しました。これで、872[ms]削減です。これらのモジュールは、C-c C-e でエクスポータのディスパッチャを呼び出す段階で逐次的に処理されます。

  (with-eval-after-load "ox"

(add-to-list 'org-modules 'ox-odt)
(add-to-list 'org-modules 'ox-org))


  • org-modules から不要なモジュールを削除

最新の Org Mode では、org-modules に、(org-w3m org-bbdb org-bibtex org-docview org-gnus org-info org-irc org-mhe org-rmail org-eww)が格納されています。不要なモジュールがあれば削除しましょう。私の場合は、org-gnusが不要なので、次のように削除します。すると、1052[ms]削減できました。

  (with-eval-after-load "org"

(setq org-modules (delete 'org-gnus org-modules)))


便利ツール

起動時にどんなパッケージやファイルを読み込んでいて、どの程度の時間を要しているのかを知るには、requireload関数の拡張を使うとよいでしょう。元ネタは、すぎゃーんメモです。感謝!さらに細かく調査したい場合は、ビルトインの profiler.el を使うと良いでしょう。意外なことに run-at-time を使う設定が diary-lib.el を読み込んでいてオーバヘッドになる、など細かいことにも気づくことができます。

;; フラグ(通常は nil にしておけばよい)

(defconst my-ad-require-p t
"If non-nil, override `require' and `load' to show loading times.")

;; init.el のはじめの方で読み込み
(when my-ad-require-p
(load "~/Dropbox/emacs.d/config/init-ad.el" nil t))


init-ad.el

;; advice of load function

(defadvice load (around require-benchmark activate)
(let* ((before (current-time))
(result ad-do-it)
(after (current-time))
(time (+ (* (- (nth 1 after) (nth 1 before)) 1000.0)
(/ (- (nth 2 after) (nth 2 before)) 1000.0)))
(arg (ad-get-arg 0)))
(message "--- %04d [ms]: (loading) %s" time arg)))

;; advice of require function
(defadvice require (around require-benchmark activate)
"http://memo.sugyan.com/entry/20120105/1325756767"
(let* ((before (current-time))
(result ad-do-it)
(after (current-time))
(time (+ (* (- (nth 1 after) (nth 1 before)) 1000.0)
(/ (- (nth 2 after) (nth 2 before)) 1000.0)))
(arg (ad-get-arg 0)))
(unless (or (memq arg '(cl-lib macroexp))
(> 0.1 time))
(message "--- %04d [ms]: %s" time arg))))

(provide 'init-ad)



おわりに

postpone.el を導入して、Emacsの起動と拡張読み込みを分離し、逐次起動する方法を紹介しました。今後は、現状で2段階の粒度をもう少し細かく制御できないか工夫してみたいです。

皆様のストレスが少しでも解消されたら、幸いです。