Common Lispで関数の
「静的変数」を作るレキシカルクロージャ
(メモリ割り当ての省略)

  • Common Lispでループ内から繰り返し呼ぶ関数にmake-arrayがあると、GCの負荷がボトルネックになる。
  • defparameterで配列をグローバルに確保しfill-pointerで使い回すと、メモリ割り当てを1回に抑えられる。
  • letdefunを組み合わせたレキシカルクロージャにすれば、配列をグローバルに露出させずに同じ効果を得られる。
  • ただし、declaim ftypeで関数宣言をしないと、コンパイル時未定義に警告が出ます。

関連記事

1. 問題:ループ内でのmake-array

ループの中で繰り返し呼ぶ関数で、毎回 make-array していて効率が悪くなっていました。

問題:ループ内の make-array 10万回ループ frequency-vector make-array ← 毎回実行 配列を確保 GC ループのたびに発火 処理が止まる 割り当て回数 = ループ回数 メモリ確保のコストが積み重なる

配列のメモリ割り当てがループ回数分だけ積み重なり、ボトルネックになっているようでした。
そこで、配列を一度だけ確保して使い回したいと思います。

たとえば、リストの要素の出現頻度を数える関数を考えます。

(defun frequency-vector (lst M)
  (let ((v (make-array (1+ M) :element-type 'fixnum :initial-element 0)))
    (loop for a_n in lst
          do (incf (aref v a_n))
          finally (return v))))Code language: Lisp (lisp)

この実装では呼ぶたびに、頻度を記録する配列を make-array しています。
1回の呼び出しならまったく問題ありませんが、これを10万回ループから呼ぶと話が変わります。

make-array はOSやアロケータとのやりとりを伴い、確保したオブジェクトはガベージコレクタの追跡対象になります。
GCが走るたびに処理が止まり、ループ全体の時間を押し上げます1

2. defparameterで配列をグローバルに確保する

まずは、素直な解決策として、配列をグローバル変数に出します。

defparameter でグローバル確保 起動時 make-array 1回だけ実行 *v* に保持 10万回ループ fill-pointer 更新 fill で初期化 配列を使い回す GC発火なし キャッシュ効率◎ ⚠ *v* がグローバルに露出 他のモジュールから書き換えられるリスクあり
(defparameter *v*
  (make-array 500001
              :element-type 'fixnum
              :fill-pointer 0
              :initial-element 0))

(defun frequency-vector (lst M)
  (declare (type fixnum M)
           (optimize (speed 3) (safety 0)))
  (setf (fill-pointer *v*) (1+ M))
  (fill *v* 0)
  (loop for a_n in lst
        do (incf (aref *v* a_n))
        finally (return *v*)))Code language: Lisp (lisp)

名前に * をつけるのはCommon Lispのダイナミック変数の慣習で、「グローバルに変更される変数」を示します2
こうすれば、make-array はプログラム起動時に1回だけ走ります3

fill-pointer はこの配列の論理的な長さを表す値で、setf で書き換えられます4
(setf (fill-pointer *v*) (1+ M)) とすれば、500001要素ぶんの実メモリはそのままに、lengthmap などのシーケンス関数には長さ M+1 の配列として見えます。

areffill-pointer を無視して全領域にアクセスできますが、シーケンス操作はすべて fill-pointer の範囲内に収まります5

fill では配列を0で上書きします。
毎回走りますが、確保済みのメモリへの連続書き込みなのでキャッシュに乗りやすく、make-array より大幅に速くなりました。

2.1. グローバル汚染の問題

ただし、defparameter による解決には欠点があります。
それは、*v* がグローバルな名前空間に露出してしまうこと。

別のモジュールや関数が誤って *v* を書き換えると、frequency-vector の動作が壊れます。
また、frequency-vector を並行して2つ呼ぶと、同じ *v* を共有するので競合します。
テストで状態をリセットしにくいという問題もあります。

3. クロージャで配列を閉じ込める

そこで、letdefun を組み合わせます。
そうすると、配列をスコープの外に見せずに保持できます。

クロージャで配列を閉じ込める let スコープ 配列 v GCされない 捕捉 frequency-vector v を使い回す(make-arrayなし) 外部から参照不可 並行呼び出しも安全 テストでリセットしやすい 🔒 スコープ外に見えない let + defun = レキシカルクロージャ グローバル汚染なし、メモリ効率◎
(let ((v (make-array 500001
                     :element-type 'fixnum
                     :fill-pointer 0
                     :initial-element 0)))
  (defun frequency-vector (lst M)
    (declare (type fixnum M)
             (optimize (speed 3) (safety 0)))
    (setf (fill-pointer v) (1+ M))
    (fill v 0)
    (loop for a_n in lst
          do (incf (aref v a_n))
          finally (return v))))Code language: Lisp (lisp)

let はレキシカルスコープを作ります。
v はこのブロックの中でしか名前で参照できません。

ただし defun で定義された frequency-vector は、自分が定義されたスコープの v を捕捉して閉じ込めます。
したがって、let のブロックを抜けても v はGCされず、frequency-vector が生きている限り存在し続けます。
これが「レキシカルクロージャ」です6

グローバル変数と違って、frequency-vector 経由でしかアクセスできなくなりました。

3.1. C言語のstaticとLispのクロージャ

この設計は、C言語の static ローカル変数と同じ発想ですが、構造には違いがあります。

C の static とクロージャの比較 C言語 static コンパイル時に確保・固定 呼び出しをまたいで値を保持 インスタンスは常に1個 実行時に複製できない Lisp クロージャ 実行時に環境を生成 呼び出しをまたいで値を保持 インスタンスを複数作れる lambda で動的に量産可能 共通の挙動 make-frequency-counter → 独立した配列を持つ関数を量産 static では不可能な設計がクロージャで実現できる

C言語の静的変数(static)は、関数呼び出しをまたいで値が保持されます。

// C Style
void frequency_vector(int *lst, int len, int M, int *result) {c
    static int v[500001];
    memset(v, 0, (M + 1) * sizeof(int));
    for (int i = 0; i < len; i++) result[lst[i]]++;
}Code language: C++ (cpp)

Lispのクロージャも同じように、呼び出しをまたいで v を保持しますが、厳密には違いがあります。
Cの static がコンパイル時に決まる固定の1個であるのに対し、クロージャは実行時に生成される独立した環境です。
それは「インスタンスを複数作れるか」ということにも関係しています。

確かに、Lispの letブロック内にdefunで書いたクロージャでは、すべて同じ変数を共有します。
しかし、Lispでは、letブロック内に lambda を使ってクロージャを生成することもできます。
そうすれば、それぞれ独立した v を持つカウンターをいくつでも作れます7

(defun make-frequency-counter (max-size)
  (let ((v (make-array (1+ max-size)
                       :element-type 'fixnum
                       :fill-pointer 0
                       :initial-element 0)))
    (lambda (lst M)
      (setf (fill-pointer v) (1+ M))
      (fill v 0)
      (loop for a_n in lst
            do (incf (aref v a_n))
            finally (return v)))))

(defparameter counter-a (make-frequency-counter 1000))
(defparameter counter-b (make-frequency-counter 5000))Code language: Lisp (lisp)

このとき、counter-acounter-b はそれぞれ独立した配列を持ち、互いに干渉しません。

つまり、defun を1つ定義するだけなら動作はCの static と同じですが、クロージャを返す関数として設計すれば状態を持った関数を動的に量産できます。

4. 補足:let+defunで出るSTYLE-WARNING

ところで、let の中に書いた defun には、少し問題があります。

; caught STYLE-WARNING:
;   undefined function: COMMON-LISP-USER::FREQUENCIES-FROM-VECTORCode language: JavaScript (javascript)

SBCLはファイルをコンパイルするとき、トップレベルの defun を先にスキャンして関数名を登録します。
ところが、let で包まれた defun はトップレベルとして認識されません。
そのため、コンパイラは frequencies-from-vector の存在を知らないまま、それを呼ぶ側の関数をコンパイルしてしまいます。

ただ、実行時には let が評価されて関数は定義されます。
したがって動作はしますが、コンパイル時点では未定義扱いなのです。

4.1. declaim ftypeでの関数宣言する

コンパイル時点でシンボルの関数定義をするには、ファイルの先頭で declaim を使い、関数の存在をコンパイラに予告します。

(declaim (ftype (function (list fixnum) (vector fixnum))
                frequency-vector))

(let ((v (make-array 500001
                     :element-type 'fixnum
                     :fill-pointer 0
                     :initial-element 0)))
  (defun frequency-vector (lst M)
    (declare (type fixnum M)
             (optimize (speed 3) (safety 0)))
    (setf (fill-pointer v) (1+ M))
    (fill v 0)
    (loop for a_n in lst
          do (incf (aref v a_n))
          finally (return v))))Code language: Lisp (lisp)

(ftype (function (list fixnum) (vector fixnum)) ...) は「引数が list fixnum、戻り値が fixnumvector の関数」という型シグネチャです。
これでコンパイラは関数の存在と型を把握できるため、let+defun の構造でも警告が出なくなります。
カプセル化を保ちたいライブラリコードではこちらが適しています。

5. まとめ

ループ内で配列を繰り返し使う関数は、make-array をループの外に出すだけでGCの負担を大きく減らせます。
fill-pointer を使えば、大きめに確保した配列を必要な長さに切り詰めて使い回せます。

defparameter で実現できますが、グローバル汚染を避けたければ letdefun を組み合わせたクロージャ構造を使います。
このクロージャは、Cの静的変数と動作は似ていますが、複数インスタンスを作れる点で異なります8

  1. Common LispのGCは多くの実装でstop-the-worldを採用しており、コレクション中はすべてのスレッドが停止します。アロケーションが増えるほどコレクション頻度が上がり、停止時間の合計が増えます。 – Pro mailing list: on Common Lisp and parallel GC
  2. この慣習は「earmuff(イヤーマフ)規約」と呼ばれます。defvardefparameter で定義された変数は自動的に special 宣言され、ダイナミックスコープで動作します。earmuffをつけることで、レキシカル変数と視覚的に区別できます。 – Common Lisp – Wikipedia
  3. defparameter はファイルをリロードするたびに値を上書きします。一方 defvar は初回のみ値を設定し、既にバインドされている場合は変更しません。起動時に一度だけ確保したい配列には defvar の方が安全な場合もあります。 – CLHS: Macro DEFPARAMETER, DEFVAR
  4. fill-pointer に指定できる値は0以上かつ array-total-size 以下の非負整数です。範囲外の値を設定するとエラーになります。 – CLHS: Accessor FILL-POINTER
  5. areffill-pointer を無視する動作はCommon Lisp標準の仕様です。fill-pointerより大きいインデックスへのアクセスは未定義ではなく、合法的に行えます。 – CLtL2: 17.5 Fill Pointers
  6. レキシカル変数への参照を持つ関数が、定義時の環境ごと保持される仕組みです。let の上に lambdadefun を載せるパターンは “let over lambda” とも呼ばれます。 – Let Over Lambda
  7. 同じ let ブロックの中に複数の defun を置くと、それらはすべて同じ v を共有するクロージャになります。ゲッターとセッターを1つのバインディングで共有する、オブジェクト指向的なカプセル化と同じ構造です。 – Successful Lisp – Chapter 11
  8. fill-pointer:adjustable t を組み合わせると、vector-push-extend で実メモリサイズ自体を動的に拡張することもできます。今回のような上限が既知のケースでは不要ですが、上限が不定の場合は選択肢になります。 – CLHS: Function ADJUST-ARRAY