bytecode

前回の最後にやった、pythonによる、たらい回し(ああ、たらい と言ったら昭 和の匂いがプンプンするな)を32Bitのマシンで再確認。ちょいと気になる点が 有たから。

まずは、オリジナル。1- が無かったので、自前で定義してそれを呼び出すや つ。

debian:__pycache__$ time python tarai.cpython-36.pyc

real    0m7.311s
user    0m7.220s
sys     0m0.008s

64Bit仮想マシン(@FreeBSD 11.2)より、良い成績だ。実機だから? それとも FreeBSDが、保守的だから? それともdebian側で使ったpythonは、アナコンダ 由来のものなんで、蛇の魔力が働いた? 考えだすと切りがないんで、この際置いておく。

気になってたのは、1-の変りに定義したm1を呼んでいる点(移植の 常套手段)。これ、呼び出しコスト がかかる処理のはず。m1使うのやめて、埋めこんじゃえ。かっこよく言えば、 インライン化。コードを曝けだすと、こんな感じ。

(defn tarai [x y z]
  (if (<= x y)
      y
    (tarai (tarai (- x 1) y z) (tarai (- y 1) z x) (tarai (- z 1) x y))))

これで実行してみると

debian:__pycache__$ time python tarai.cpython-36.pyc

real    0m5.170s
user    0m5.052s
sys     0m0.004s

2秒も速くなった。でも、そのためにインライン化するの面倒。そういう事を Lispに携わる人が言ってはいけません。得意のマクロが有るじゃないですか。

benchstat

前回の続きで、benchmarkの事について調べていたんだ。そしたら、 How do I measure performance of elisp code?

なんてのがヒットした。profileerが使えるのね。 Emacs-24.3からネイティブプロファイラが使えてた 更に 注目は、最後のお勧めのところ。なにやらかっこいい 体裁で結果が表示される。

Quasilyte/benchstat.el

で、データを収集して、その結果を統計的にまとめるには、goのアプリを使っ てる。

(require 'benchstat)

(defconst repetitions 10)

(benchstat-run :old repetitions (xx 1))

(benchstat-run :new repetitions (xxNew 1))

(benchstat-compare)

以前に取り上げた、ひたすら計算するベンチコードxxとそれをbyte-compileし た、xxNewを俎上に載せてみた。

name   old time/op    new time/op    delta
Emacs     887ms ± 0%     652ms ± 4%  -26.48%  (p=0.000 n=8+9)

name   old allocs/op  new allocs/op  delta
Emacs       318 ± 0%       318 ± 0%     ~     (all equal)

演算時間は、26%高速化したけど、メモリーの使いっぷりは、一緒って結論に なりました。

(/ (- 887 652) 887.0)
0.2649379932356257

ielm

今までemacsで実験コードを試すには、*scrache* を使っていた。でも、考え るに、lispなんだから、正統派のreplが有ってもよさそう。調べたら出てきた。

Evaluating Elisp in Emacs

(ac-config-default)
(add-hook 'ielm-mode-hook 'ac-emacs-lisp-mode-setup)
(add-to-list 'ac-modes 'inferior-emacs-lisp-mode)

こんな設定をしておくと、補完できるぞ(少々うざいけど)。 C-c C-bすると、バッファーを切りかえられる。

M-x ielm for elisp-repl

*** Welcome to IELM ***  Type (describe-mode) for help.
ELISP> (defun ss(k)
           (let ((n (* k 1000))
                         (s ""))
               (dotimes (i n)
                     (setq s (concat s "Hello ELISP")))))
ss
ELISP> (benchmark-run (ss 4))
(1.072022584 61 0.951640164)

ELISP> (byte-compile 'ss)
#[(k)
  "^H\305_\306^Y\211^Z\307^[^\^K\fW\205^[^@     \310P^Q^KT\211^S\202^K^@,\207"
  [k s n i --dotimes-limit-- 1000 "" 0 "Hello ELISP"]
  3]

ELISP> (benchmark-run (ss 4))
(0.370674901 15 0.23920097399999984)

前回取り忘れていた、文字列連結ベンチマーク。@ CentOS

ELISP> (/ 0.951640164 61)
0.015600658426229508
ELISP> (/ 0.23920097399999984 15)
0.01594673159999999
ELISP> (- 1.072022584  0.951640164)
0.12038241999999999
ELISP> (- 0.370674901  0.23920097399999984)
0.13147392700000016

最初の2つの割り算は、1回にかかるGCの時間。bytecodeに関係ない。次の2つ の引き算は、GC時間を除いた、演算時間。これは差がない。よって、GCの回数 減少で、スピードアップの効果が出ているんだな。

ってな事が、replが動いていると、簡単に確認できる。

load

定義した関数の置き場所は、どうなってるか調べてみた。

ELISP> (load-file "tarai.el")
t
ELISP> (symbol-function 'tarai)
(lambda
  (x y z)
  (if
      (<= x y)
      y
    (tarai
     (tarai
      (1- x)
      y z)
     (tarai
      (1- y)
      z x)
     (tarai
      (1- z)
      x y))))

ELISP> (load-file "tarai.elc")
t
ELISP> (symbol-function 'tarai)
#[(x y z)
  "^H   X\203^H^@       \207\303\211^HS \n#\303 S\n^H#\303\nS^H ##\207"
  [x y z tarai]
  7]

コンパイル前だと、文字列のまま格納されてる。コンパイルされたコードも、 同じ場所に入るんだな。

ELISP> (setq tarai "hogefuga")
"hogefuga"
ELISP> (symbol-value 'tarai)
"hogefuga"
ELISP> (tarai 12 6 0)
12 (#o14, #xc, ?\C-l)

関数と同名の変数を設定しても、関数が壊される事は無い。ここがschemeと違 う所だ。

dis and compile

ELISP> (disassemble
        (byte-compile-sexp '(list (+ i 3) (* i 4))))

S式をコンパイルして、それを即座に逆アセンブルしちゃう慌しい例。

byte code:
  args: nil
0       varref    i
1       constant  3
2       plus
3       varref    i
4       constant  4
5       mult
6       list2
7       return

勿論コンパイル済みのコードは、何時でも逆アセンブル出来る。

ELISP> (disassemble 'tarai)

byte code for tarai:
  args: (x y z)
0       varref    x
1       varref    y
2       leq
3       goto-if-nil 1
6       varref    y
7       return
8:1     constant  tarai
9       dup
10      varref    x
11      sub1
12      varref    y
13      varref    z
14      call      3
 :

この逆アセンブラ、ちょいとやり過ぎの感じがするぞ。xxはdotimesというマ クロを使っている。マクロは、展開して格納されてるんだろうなと思って、 確認したんだ。

ELISP> (symbol-function 'xx)
(lambda
  (m)
  (let
      ((n
        (* m 1000 1000)))
    (let
        ((--dotimes-limit-- n)
         (i 0))
      (while
          (< i --dotimes-limit--)
        (+
         (+ i 0.1)
         (- i 0.1)
         (- i 0.2)
         (+ i 0.2))
        (setq i
              (1+ i))))))

思った通り、展開形になってた。この状態で、逆アセすると、

byte code for xx:
  args: (m)
0       varref    m
1       constant  1000000
2       mult
3       dup
4       varbind   n
5       constant  0
6       varbind   i
7       varbind   --dotimes-limit--
8       varref    i
9:1     varref    --dotimes-limit--
10      lss
11      goto-if-nil-else-pop 2
14      constant  +
 :

あたかも、コンパイル済みって形で表示される。人を惑わす仕様だな。騙され れて、時間詐欺にあわないように。

1000 * 1000 は、簡約と言うか、定数の畳み込みが行なわれているな。

disassemble is an interactive autoloaded compiled Lisp function in
`disass.el'.

(disassemble OBJECT &optional BUFFER INDENT INTERACTIVE-P)

Print disassembled code for OBJECT in (optional) BUFFER.
OBJECT can be a symbol defined as a function, or a function itself
(a lambda expression or a compiled-function object).
If OBJECT is not already compiled, we compile it, but do not
redefine OBJECT if it is a symbol.

最後の2行が、邪魔者よ悩。

まてまて、これは89(hack)用の仕様じゃなかろうか。昔、あの方が、アレグロ から、正規表現の高速化を受けおった。最後は逆アセのコードとLispコードを 見比べながら、チューニングしたとか。そんな場面なら、コードをちょっと変 えて、マシンサイクルがどうなるか、爪に火を灯すような事をされたんだろう。

そんな時は、ありがたい鴨。amd64のサイクル数を把握されてる様な場合は、 開発のターンアラウンドが効率的になるんだろうね。

オイラーの場合は、 バイトコード リファレンス を見ながら、4989すればいいんだな。

;;; -*- lexical-binding: t -*-

とか言いながら、emacsには、魔法の1行が有るそうだ。上記のコメントを、 *.el の冒頭に書いておくだけって、お手軽な方法。いわゆる、プラグマだな。 こいつが書かれていると、高速モード用のbyte-codeを吐き出してくれるそう だ。なんじゃいな、レキシカルスコープって。

レキシカルスコープ/ダイナミックスコープとは?

既存関数内で使われている変数を外部から上書きする

レキシカルスコープとダイナミックスコープの違い

早速、魔法の効き具合を確認してみる。ss,xxは、魔法のコメント無しでコン パイルしたもの。頭にLが付いている方は、コメントで、lexical-bindingを宣 言した関数。

ELISP> (benchmark-run (ss 4))
(0.322944241 15 0.1950509459999994)

ELISP> (benchmark-run (Lss 4))
(0.325183891 15 0.19779132499999985)

ELISP> (benchmark-run (xx 1))
(0.604789484 31 0.41600492199999883)

ELISP> (benchmark-run (Lxx 1))
(0.562540041 31 0.4165923730000003)

これを見ると、余り効果が感じられないな。ならば、taraiはどうよ?

ELISP> (benchmark-run (tarai 12 6 0))
(1.663158058 0 0.0)

ELISP> (benchmark-run (Ltarai 12 6 0))
(0.90538161 0 0.0)

こちらは、効果絶大。実行時間が、元の54%までなった。魔法が上手く効くの と効かないのと、有るって事ね。何か体質に違いが有るのかしらん?

ELISP> (symbol-function 'ss)
#[(k)
  "^H\305_\306^Y\211^Z\307^[^\^K\fW\205^[^@     \310P^Q^KT\211^S\202^K^@,\207"
  [k s n i --dotimes-limit-- 1000 "" 0 "Hello ELISP"]
  3]

ELISP> (symbol-function 'Lss)
#[257 "\211\300_\301^A\302\211^BW\205^Z^@\211^C\303P\262^D\210\211T\262^A\202^F\
^@\266\202\207"
      [1000 "" 0 "Hello ELISP"]
      8
      ("/tmp/Ltest.elc" . 590)]

変数リストから5個分が消えて、スタックに配置されたたっぽい。 本質的な部分で時間がかかっているんで(多分concat2が呼び出す、メモリー確 保)、工夫したのが相殺されちゃってるの かな?

逆アセしたのをpasteして並べておく(一部横幅に合わせて加工)。 6:1 とかの1は、逆アセが振ったラベル。

        ss                              Lss
----------------------------    -------------------------------
0       varref    k             0       dup
1       constant  1000          1       constant  1000
2       mult                    2       mult
3       constant  ""            3       constant  ""
4       varbind   s             4       stack-ref 1
5       dup                     5       constant  0
6       varbind   n             6:1     dup
7       constant  0             7       stack-ref 2
8       varbind   i             8       lss
9       varbind   --limit--     9       goto-if-nil-else-pop 2
10      varref    i             12      dup
11:1    varref    --limit--     13      stack-ref 3
12      lss                     14      constant  "Hello ELISP"
13      goto-if-nil-else-pop 2  15      concat2
16      varref    s             16      stack-set 4
17      constant  "Hello ELISP" 18      discard
18      concat2                 19      dup
19      varset    s             20      add1
20      varref    i             21      stack-set 1
21      add1                    23      goto      1
22      dup                     26:2    discardN-preserve-tos 2
23      varset    i             28      return
24      goto      1
27:2    unbind    4
28      return

varrefとかが、レキシカルスコープの方では、stack-refに置き変えられてい るのが、特徴的だな。

        tarai                           Ltarai
---------------------------     ----------------------------
0       varref    x             0       stack-ref 2
1       varref    y             1       stack-ref 2
2       leq                     2       leq
3       goto-if-nil 1           3       goto-if-nil 1
6       varref    y             6       stack-ref 1
7       return                  7       return
8:1     constant  tarai         8:1     constant  Ltarai
9       dup                     9       dup
10      varref    x             10      stack-ref 4
11      sub1                    11      sub1
12      varref    y             12      stack-ref 4
13      varref    z             13      stack-ref 4
14      call      3             14      call      3
15      constant  tarai         15      constant  Ltarai
16      varref    y             16      stack-ref 4
17      sub1                    17      sub1
18      varref    z             18      stack-ref 4
19      varref    x             19      stack-ref 7
20      call      3             21      call      3
21      constant  tarai         22      constant  Ltarai
22      varref    z             23      stack-ref 4
23      sub1                    24      sub1
24      varref    x             25      stack-ref 7
25      varref    y             27      stack-ref 7
26      call      3             29      call      3
27      call      3             30      call      3
28      return                  31      return

stack-refに置き変わっているのは、Lssと同じ。一瞬、stack-refのオペラン ドが間違っているんでは、と思うけど、PC相対なアドレッシングなの で、これで正しいはず(ですよね?)。

速度差が生まれるのは、方やvarrefでのアクセスでコストがかかり、レキシカ ルな方は、スタックへのアクセスだけで済むから? 塵も積もれば山となるっ てのの典型例かな?

stack-ref 7 は、2Byteのオペランドになってるね。

所で、taraiは何回呼ばれている? ちょいとカウンターを仕込んで計数してみ る。

;;; -*- lexical-binding: t -*-

(defun tarai (x y z)
  (setq cnt (1+ cnt))
  (if (<= x y)
      y
    (tarai (tarai (1- x) y z) (tarai (1- y) z x) (tarai (1- z) x y))))

(defun ct (x y z)
  (let ((cnt 0))
    (tarai x y z)
    cnt))

上記でコンパイルすると、余計なものは混ぜちゃダメって怒られる。 レキシカルバインディングの弊害?かな。

Compiling file /tmp/aa.el at Wed Jan 16 15:43:49 2019

In tarai:
aa.el:4:9:Warning: reference to free variable ‘cnt’
aa.el:4:17:Warning: assignment to free variable ‘cnt’

でも警告だからと思って、実行すると、

ELISP> (ct 12 6 0)
*** Eval error ***  Symbol’s value as variable is void: cnt

この通り、実行させてもらえなかった。警告は無視するなって典型ですな。 しょうがない ので、コメントを(コメント化は出来ない)外して、再コンパイルしたよ。 (コメントと言うかプラグマを無効にするには、2行目以降に移せば良い。)

ELISP> (ct 12 6 0)
12604861 (#o60052675, #xc055bd)

ふーむ、1260万回も呼び出されているよ。正に塵が積るんだろうね。