2019年7月23日火曜日

define-source-transform

sb-c:define-source-transformの使い方について。

例えば、動的なmodに対しての掛け算mod*を定義したいとする:

(declaim ((unsigned-byte 32) *modulus*))
(defvar *modulus*)

(defun mod* (&rest args)
  (reduce (lambda (x y) (mod (* x y) *modulus*))
          args
          :initial-value 1))

(ここでは演算の性質は問題にしない。)この mod*はどんな引数に対してもいちいちreduceを呼ぶし、mod*も引数の型に特定化されないのであまり効率が良くない。多くの場合、このような問題はインライン宣言で解決する。しかしこの場合については、mod*をインライン化することでargsの長さなどがコンパイル時に確定できても、*modを使ったフォームに展開されたりはしない。SBCLのreduceには何のコンパイル時変換も定義されていないためだ。こういう時にはdefine-compiler-macroが使える:

(define-compiler-macro mod* (&rest args)
  (if (null args)
      1
      (reduce (lambda (x y) `(mod (* ,x ,y) *modulus*)) args)))

こうすれば、(mod* a b c)(MOD (* (MOD (* A B) *MODULUS*) C) *MODULUS*)のように展開されることになる。

しかし、コンパイラマクロによる変換には本質的な制約がある: コンパイラマクロはあらゆる定数伝搬の前に展開される。例えば、三乗を求める関数cubeがあったとする:

(declaim (inline cube))
(defun cube (operator x)
  (funcall operator x x x))

(defun test (x)
  (declare (optimize (speed 3)))
  (cube #'mod* x))
  
; disassembly for TEST
; Size: 36 bytes. Origin: #x1007F7EB96
; 96: 840425F8FF1020   TEST AL, [#x2010FFF8]        ; no-arg-parsing entry point
                                                    ; safepoint
; 9D: 488BD0           MOV RDX, RAX
; A0: 488BF8           MOV RDI, RAX
; A3: 488BF0           MOV RSI, RAX
; A6: 488B059BFFFFFF   MOV RAX, [RIP-101]           ; #<SB-KERNEL:FDEFN MOD*>
; AD: B906000000       MOV ECX, 6
; B2: FF7508           PUSH QWORD PTR [RBP+8]
; B5: FF6009           JMP QWORD PTR [RAX+9]
; B8: CC0F             BREAK 15                     ; Invalid argument count trap

test内でcubeがインライン展開されるが、そのインライン展開の中でさらにmod*が展開されたりはしない。コンパイラマクロが展開されるタイミングを考えれば当然ではある。

さて、このような制約からか、SBCL内部のコード変換にはdefine-compiler-macroはほとんど使われていない。SBCLの標準の関数の多くは、これに似ているがより強力なsb-c:define-source-transformで変換されている。[1]

define-source-transformの使い方とコンパイラマクロとの違い

sb-c:define-source-transformの使い方はdefine-compiler-macroとほとんど同じである。

;; コンパイラマクロ付きのmod*
(defun mod*-with-compiler-macro (&rest args)
  (reduce (lambda (x y) (mod (* x y) *modulus*))
          args
          :initial-value 1))

(define-compiler-macro mod*-with-compiler-macro (&whole whole &rest args)
  (print whole)
  (if (null args)
      1
      (reduce (lambda (x y) `(mod (* ,x ,y) *modulus*)) args)))

;; source-transform付きのmod*
(defun mod*-with-source-transform (&rest args)
  (reduce (lambda (x y) (mod (* x y) *modulus*))
          args
          :initial-value 1))

(sb-c:define-source-transform mod*-with-source-transform (&whole whole &rest args)
  (print whole)
  (if (null args)
      1
      (reduce (lambda (x y) `(mod (* ,x ,y) *modulus*)) args)))

(defun test (x)
  (declare (optimize (speed 3))
           ((unsigned-byte 32) x))
  (let ((*modulus* 10007))
    (funcall #'mod*-with-compiler-macro x x x)               ; 1
    (funcall #'mod*-with-source-transform x x x)             ; 2
    (multiple-value-call #'mod*-with-compiler-macro x x x)   ; 3
    (multiple-value-call #'mod*-with-source-transform x x x) ; 4
    (apply #'mod*-with-compiler-macro (list x x x))          ; 5
    (apply #'mod*-with-source-transform (list x x x))        ; 6
    (cube #'mod*-with-compiler-macro x)                      ; 7
    (cube #'mod*-with-source-transform x)                    ; 8
))

testの中には三乗を計算する同等のフォームが8つ並べてある。上のコンパイラマクロとsource-transformにはprintを付けてあるので、testをコンパイルすると&whole引数を束縛しているフォームが次にようにいくつか出力される。(数字が抜けている部分は展開されなかったということである。)

1: (FUNCALL #'MOD*-WITH-COMPILER-MACRO X X X) 
2: (#<SB-C::GLOBAL-VAR
   :%SOURCE-NAME MOD*-WITH-SOURCE-TRANSFORM
   :TYPE #<SB-KERNEL:BUILT-IN-CLASSOID FUNCTION (read-only)>
   :DEFINED-TYPE #<SB-KERNEL:FUN-TYPE (FUNCTION * (VALUES T &OPTIONAL))>
   :WHERE-FROM :DEFINED
   :KIND :GLOBAL-FUNCTION {1003DB5963}>
 X X X) 
4: (MOD*-WITH-SOURCE-TRANSFORM #:G34 #:G35 #:G36) 
6: (MOD*-WITH-SOURCE-TRANSFORM #:G47 #:G48 #:G49) 
8: (MOD*-WITH-SOURCE-TRANSFORM #:G78 #:G79 #:G80)

以下、コンパイラマクロとの差異について。

  1. コンパイラマクロは定数伝搬の前の展開しかできないが、source-transformは後にも適用される。8を見ればわかるように、インライン展開されたcubeの中のoperator#'mod*-with-source-transformであると推論されたあとに展開が起きている。コンパイラマクロではこのようなことはできない。(とはいえ、この種の最適化が常に完璧に行われるわけではないので過度に期待してはいけない。)

  2. 1と2の違いからわかるように、(funcall <function> <args>*)形式のフォームが渡されたとき、コンパイラマクロは&wholeをそのままのフォームに束縛するが、source-transformは(<function> <args>*)形式に直して束縛する。

  3. 3-6で示されるように、コンパイラマクロはmultiple-value-callで呼び出される関数を変換できない。source-transformは(引数の数が決定できれば)変換できる。applyも同様。[2]

  1. コンパイラマクロでマクロ展開を放棄したい時は&wholeを束縛しているフォームをそのまま返せばよい。しかし、source-transformで同様のことをすると、返したフォームに同じsource-transformが無限に適用されてSBCLが落ちてしまう。代わりに、source-transformでは2つ目の返り値でnon-nilを返すことによりこの変換を適用しないことを通知する:
;; 展開を放棄する場合の書き方
(define-compiler-macro foo (&whole form arg) form) ; OK
(sb-c:define-source-transform foo (&whole form arg) form) ; NG
(sb-c:define-source-transform foo (&whole form arg) (values nil t)) ; OK
  1. source-transformのラムダリストは、元の関数のラムダリストと正確に一致している必要はない。例えば、mod*に引数が1つの場合のためのsource-transformを定義しても問題ない: (sb-c:define-source-transform mod* (x) x)。この場合、source-transformは引数が1つとわかっているケースでのみ呼び出される。この挙動の利用例はSBCLのソースにも見つかるので、想定された使い方だと思われる。ただし、source-transformは(deftransformと違って)1つの関数につき1つしか定義できないため、複数定義して引数の数でディスパッチするようなことはできない。そのような分岐は1つのsource-transformの中で行う必要がある。例えばgcdなどのsource-transformが参考になる。[3]
  1. source-transformは、コンパイラマクロと違ってマクロには定義できない。もっとも、コンパイラマクロをマクロに定義する例はほとんどないようなので、重要ではなさそう。

  2. source-transformによる変換はnotinlineで阻止できる。これはコンパイラマクロと同じ。

  3. define-source-transformで定義される展開器は(sb-int:info :function :source-transform <symbol>)で得ることができて、compiler-macro-functionなどと同じように使える。

  4. source-transformの展開をREPLで確認したい場合はg000001さんの記事の通りにすれば良い。ただ、記事が書かれた時とはsource-transformの仕様が変わっているようなので(source-transformが必ずenvironmentを引数に取るようになっている?)SBCL1.4.14で使える形をメモしておく:

(defun source-transform-expand (form &optional (env (sb-kernel:make-null-lexenv)))
  (if (and (consp form)
           (symbolp (car form))
           (not (special-operator-p (car form))) )
      (let ((sb-c::*lexenv* env))
        (or (and (fboundp (car form))
                 (multiple-value-bind (fun win)
                     (sb-int:info :function :source-transform (car form))
                   (and win (funcall fun form env))))
            (values form T)))
      (values form T)))

(source-transform-expand '(apply #'+ (list a b c)))
;; |-> (MULTIPLE-VALUE-CALL #'+ (VALUES-LIST (LIST A B C)))
  1. define-source-transform(からマクロ展開されたフォーム)はコンパイル時に評価されるわけではない。必要ならeval-whenを使う。

  1. もっとも、コメントを読む限りではsource-transformがそのために作られたわけではなさそうだ。 ↩︎

  2. なお、SBCLでは(apply <function> <list>)自体がsource-transformで(multiple-value-call <function> (values-list <list>))変換される↩︎ ↩︎

  3. gcdのsource-transformは(gcd a b)のような2引数のケースでは変換しないようになっている。これも、(gcd a b)に対して(gcd a b)を返すと無限に展開されてしまうためである。 ↩︎