- SBCLには
timeとsb-sprofという2つの計測ツールが同梱されており、コードのどこが遅いかを実測で特定できます。 timeは実行時間とメモリ確保量を出力する組み込み関数で、改善前後の比較に使います。sb-sprofは統計的プロファイラで、一定間隔でコールスタックをサンプリングし、時間を多く消費している関数を割合で示します。appendによる末尾追加のようなO(n²)の処理は、pushとnreverseを組み合わせたO(n)に変えるだけで、実行時間とメモリ確保量が大幅に改善されます。
1. timeとsb-sprof とは
Common Lisp でコードが遅いときに、勘で「どこが重いか」を追ってもなかなか直せません。
SBCL には、timeという組み込みのパフォーマンス測定関数と sb-sprof という統計的プロファイラが同梱されていて、どの関数で時間を使っているかを実測できます。
パフォーマンス調査の基本的な流れは、
timeで全体の実行時間を確認するsb-sprofでどの関数が上位に来るかを見る- 上位の関数を改善する(アルゴリズム変更、不要な cons を減らす、など)
- 再び
timeで改善前後を比較する
2. timeで実行時間を測定する
sb-sprof を使う前に、time 関数で全体の実行時間を測っておきます。time は SBCL 組み込みで、実行時間とメモリ確保量を出力します。
(time (your-function parameters))Code language: Lisp (lisp)
たとえば、リストの末尾に要素を追加し続けるパターンがあります。
ここでは、build-list-naive と build-list-fast の計測を比べてみます。
;; O(n²) — append を使って末尾に追加し続ける
(defun build-list-naive (n)
(let ((result '()))
(dotimes (i n result)
(setf result (append result (list i))))))
;; O(n) — push で先頭に積んで最後に nreverse する
(defun build-list-fast (n)
(let ((result '()))
(dotimes (i n)
(push i result))
(nreverse result)))Code language: Lisp (lisp)
append を使う版は、第 1 引数のリスト全体をコピーしてから第 2 引数を繋ぐため、result が 0, 1, 2, … と伸びるにつれコピーコストも増えるので、合計 O(n²) になります。
一方、push は先頭に cons セルを 1 つ作るだけで、最後の nreverse に O(n)。
これを計測するために、繰り返しのベンチマーク関数を用意しました。
(defun bench-naive (n repeat)
(dotimes (k repeat) (build-list-naive n)))
(defun bench-fast (n repeat)
(dotimes (k repeat) (build-list-fast n)))
(time (bench-naive 3000 200))
(time (bench-fast 3000 200))Code language: Lisp (lisp)
2.1. 計算量の差を実測で確認する
実行すると、たとえばこのような差が出ます。
* (time (bench-naive 3000 200))
Evaluation took:
5.621 seconds of real time
5.203932 seconds of total run time (4.971306 user, 0.232626 system)
[ Real times consist of 0.619 seconds GC time, and 5.002 seconds non-GC time. ]
[ Run times consist of 0.577 seconds GC time, and 4.627 seconds non-GC time. ]
92.58% CPU
9,039,625,047 processor cycles
3 page faults
14,404,781,104 bytes consed
;; bench-fast
* (time (bench-fast 3000 200))
Evaluation took:
0.006 seconds of real time
0.005807 seconds of total run time (0.005718 user, 0.000089 system)
100.00% CPU
9,924,337 processor cycles
9,596,336 bytes consedCode language: Lisp (lisp)

速い方は0.006秒なのに対して遅い方は、5.6秒もかかっています。
また、時間だけでなくメモリ確保量(bytes consed)も、9MBと14GBと大きな差がありますね。
アルゴリズムの計算量が本当に違うかを確認するには、n を段階的に増やして time で比較するのが分かりやすいです。
(time (build-list-naive 1000))
(time (build-list-naive 2000))
(time (build-list-naive 4000))Code language: Lisp (lisp)
GC のタイミングや処理系の最適化で多少ぶれますが、O(n²) は n を 2 倍にすると時間が約 4 倍になるという傾向が出てきます。
3. sb-sprof で重い関数を特定する
さて、build-list-naiveのどの部分が足を引っ張っているか確認するのが、統計的プロファイラです。
sb-sprof は、一定間隔(デフォルトで約 0.01 秒)でその瞬間のコールスタックをサンプリングして、どの関数が何回サンプルに引っかかったかを集計します1。
これによりサンプルが多い関数ほど、そこで時間を使っていると推定できるわけです。
ちなみに、SBCL にはもう一つ
sb-profileというプロファイラもあります。こちらは対象関数に計測コードを差し込む方式で、呼び出し回数を正確に数えられます2。
sb-sprof は contrib(同梱の追加モジュール)なので、最初に require で読み込みます。
(require :sb-sprof)Code language: Lisp (lisp)
次に、計測したい処理を with-profiling で囲みます3。
(sb-sprof:with-profiling (:max-samples 3000 :report :flat)
(bench-naive 3000 200))Code language: Lisp (lisp)
出力はこのような形になります。
Number of samples: 456
Sample interval: 0.01 seconds
Total sampling time: 4.56 seconds
Graph cycles: 0
Self Total Cumul
Nr Count % Count % Count % Calls Function
------------------------------------------------------------------------
1 321 70.4 341 74.8 321 70.4 - SB-IMPL::APPEND2
2 99 21.7 99 21.7 420 92.1 - foreign function __pthread_sigmask
3 14 3.1 14 3.1 434 95.2 - foreign function __psynch_cvwait
4 11 2.4 11 2.4 445 97.6 - FPR-SAVE
5 3 0.7 3 0.7 448 98.2 - foreign function gc_close_region
6 2 0.4 7 1.5 450 98.7 - foreign function lisp_alloc
7 2 0.4 2 0.4 452 99.1 - foreign function tlv_get_addr
8 1 0.2 438 96.1 453 99.3 - BUILD-LIST-NAIVE
9 1 0.2 1 0.2 454 99.6 - foreign function semaphore_wait_trap
10 1 0.2 1 0.2 455 99.8 - foreign function gc_alloc_new_region
11 1 0.2 1 0.2 456 100.0 - foreign function pthread_mutex_lock
...Code language: CSS (css)

APPEND が全体の 70.4 %と大きく、末尾追加のたびにリスト全体をコピーしているのが、処理時間の大部分を占めているとわかります。
3.1. flatレポートの読み方
flat レポートの主な列は次の通りです。
| 列 | 意味 |
|---|---|
Self Count / % | その関数自身でサンプルされた回数と割合 |
Total Count / % | その関数の呼び出し先も含めた割合 |
Function | 関数名 |
最初は Self % が高い関数に注目するだけで十分です。
そこが実際に時間を使っている場所です。Total % が高くても Self % が低い場合は、呼び出し先のどこかが重いので、呼び出し先を見に行きます。
サンプリングベースなので結果は多少ぶれます。
気になる場合は同じ計測を複数回行って、傾向が一致するか確認してみてください4。
3.2. Number of samples: 0
ちなみに、sb-sprof でよくある失敗が、Number of samples: 0 という結果です。
というのも、関数 1 回の実行が 0.01 秒より短いと、サンプリングのタイミングに全く引っかからないからです5。
測りたい処理を何万回も繰り返す「ベンチ関数」を用意して、数秒程度は走らせてください。
3.3. with-profilingのオプション
主なオプションは次の通りです。
:max-samples— 取得するサンプルの最大数。
多いほど精度が上がりますが、長く走らせる必要があります。:report— 結果の表示形式。:flatは関数ごとの集計、:graphは呼び出し木を出します。
最初は:flatで十分です。:mode— デフォルトは:cpu(CPU 時間でサンプリング)。:allocにするとメモリ確保の多い場所を調べられます6。
メモリ確保が多い場合は :mode :alloc でアロケーションプロファイルを取ると、どこで大量に cons しているかも分かります。
(sb-sprof:with-profiling (:max-samples 3000 :report :flat :mode :alloc)
(bench-naive 3000 200))Code language: Lisp (lisp)- デフォルトのサンプリング間隔は
*sample-interval*変数で制御されており、初期値は0.01秒です。with-profilingの:sample-intervalキーワードで変更できます。 – SBCL ソースコード sb-sprof/interface.lisp - SBCL の公式マニュアルでは
sb-profileを “exact per-function profiler”、sb-sprofを “statistical profiler” と区別して案内しています。 – SBCL User Manual - マルチスレッド環境では、
with-profilingを実行したスレッドのみがデフォルトでプロファイリングされます。複数スレッドを同時に計測したい場合はsb-sprof:start-profilingに:threads :allを渡す方法もあります。 – SBCL User Manual – Statistical Profiler sb-sprofは元々 Gerd Moellmann が CMUCL 向けに開発した統計的プロファイラを SBCL に移植・拡張したものです。cl-flamegraph など、sb-sprofの結果をフレームグラフ形式で可視化するラッパーライブラリも公開されています。 – Common Lisp Cookbook – Performance- サンプルが取れなかった場合、SBCL は “No sampling progress; run too short, sampling interval too long, inappropriate set of sampled thread, or possibly a profiler bug.” という警告を出力します。サンプリング間隔を短くするか、計測対象を長く走らせることで解消できます。 – SBCL devel mailing list
:mode :allocは世代別 GC(generational garbage collector)を使用する SBCL ビルドでのみサポートされています。また、2レベルを超える呼び出しスタックの追跡は x86 および x86-64 でのみ対応しています。 – SBCL User Manual – Statistical Profiler