Common Lispでyesコマンドを実装した

この記事 Haskell で yes コマンドを実装した に触発されて、yesコマンドをCommon Lispでモダンに書いてみました。

速度があまり速くなく、識者のご指摘をいただきたいところです。

2017/12/01追記 ご指摘いただけました! 後ろの方に追記しました。

コード

package-inferred-systemで定義されていてroswellスクリプトをもつ、こんな感じの構成です。トリビアル

github.com

cl-yes$ tree
.
├── README.md
├── cl-yes.asd
├── main.lisp
└── roswell
    ├── yes
    └── yes.ros

1 directory, 5 files

速度測定

では、速度を測定します。

GNU Coreutilsのyes

リファレンスとして、ふつうのyesの速度を測ります。

$ yes | pv -r > /dev/null
[9.28GiB/s]

あらまー。10秒走らせたら100GBですって。はやいですねー。

yes.ros

Common Lispの処理系はこんな感じでございます。

$ ros config
externals.clasp.version=5.0
setup.time=3718530120
sbcl-bin.version=1.4.1
default.lisp=sbcl-bin

文字列を出力しつづけるyes関数はこんな感じでございます。シンプル。

(defun yes (out &optional (s "y"))
  (loop
     (write-line s out)))

とりあえず実行してみると、

$ ./roswell/yes.ros | pv -r > /dev/null
[1.86MiB/s]

ええ、遅い…。ビルドしてみるとどうなるでしょうか。

$ ./yes | pv -r > /dev/null
[3.04MiB/s]

出だしの速度が改善されただけでした(ビルド前も5秒待つと、これくらいの数値)。なにが原因なのでしょう。試しにprinc関数に書き換えてみます。

(defun yes (out &optional (s "y"))
  (loop
     (princ s out)
     (terpri out)))
$ ./yes | pv -r > /dev/null
[4.62MiB/s]

ちょっとだけ速くなりましたが、なんでprincのほうが速いんだ。formatでも同じくらい。

(defun yes (out &optional (s "y"))
  (loop
     (format t "~a~%" s)))
$ ./yes | pv -r > /dev/null
[3.59MiB/s]

ちなみに最適化宣言をしても、とくに結果は変わりませんでした。

(defun yes (out &optional (s "y"))
  (declare (optimize (speed 3) (debug 0) (safety 0)))
  (loop
     (format t "~a~%" s)))
$ ./yes | pv -r > /dev/null
[3.04MiB/s]

というか、むしろちょっぴり遅くなった…?

そもそもGNU coreutilsのyesが謎の速さであって、そっちがおかしいのだろうか…。

ううむ、識者のご指摘がほしい…。

速い出力への道 (追記)

この記事を公開したところ、佐野さんから「バッファすると速いよ」とご指摘いただきました。そして併わせて、CFFIを通じてCの文字列でバッファする実装のコードが。こちらを手元の環境で実行すると、本物のyes並の速度が得られました…!

$ ./yes | pv -r > /dev/null
[9.33GiB/s]

同じ内容を吐くだけなら一度だけバッファに溜めて、あとはそれをまとめて出力すればいいのです。目的に合わせて柔軟に思考しましょう。GNU Coreutilsの実装もまさにそのようになっています

では、これをCommon Lispだけでやるとどうなるのか。ちょっと気になりますね。なのでやってみました。

(defun make-buffer (s &optional (times 10000))
  (with-output-to-string (out)
    (dotimes (n times)
      (write-line s out))))

(defun yes (out &optional (s "y"))
  (let ((buf (make-buffer s)))
    (loop
       (princ buf out))))
$ ./yes.ros | pv -r > /dev/null
[ 358MiB/s]

速度は、愚直な実装よりはずいぶん速くなりましたが、バッファのサイズに関わらずこの数字で頭打ちになります。GNU Coreutils yesの4%くらい…。Common Lispのストリームを挟むと遅いんでしょうかね。

まとめ (追加)

Common Lispにおいて猛烈な出力速度が求められる場合は、CFFIを使ってCの配列に出力内容を自前でバッファするのがベストプラクティスであるようです。