Go 言語で wc を実装してみた

Go 言語で wc を実装してみた

GitHub - takatoshiono/go-wc: Go implementation of wc command for practice

なぜか

A Tour of Go をやり終えた時「全然うまく書けない」というのが感想だった。もっと Go 言語のコードを読み書きする必要がある。

そして読むだけだとやる気が続かないから何か書きたい。何を作ろうか?

Go 言語なのでスタンドアローンで起動するバイナリ実行形式のファイルがよさそう。仕様が簡単で手頃なやつがいいな...と考えて wc にしたのだった。他にも以下が候補にあった。

  • ab
  • smtp server
  • beer コマンド(なんかうまそうなビールを表示する)
  • wc コマンド
  • find コマンド
  • (コマンド系で攻めるなら GNU coreutils, findutils などを見るとよさそうか...)
  • MySQLバイナリログを受け取ってフィルタリングする君

wc とは

Wikipedia から引用。

wc(ダブリューシー、Word Count の略)はUNIX系オペレーティングシステムのコマンドの一種。
標準入力か指定されたファイルのリストを読み込み、各種統計情報を生成する。出力情報としては、バイト数、単語数、行数(改行文字の個数)などがある。複数のファイル名を指定した場合、各ファイルの情報と合計が表示される。
wc の実行例を以下に示す。
$ wc ideas.txt excerpt.txt 
     40     149     947 ideas.txt
   2294   16638   97724 excerpt.txt
   2334   16787   98671 total
最初のカラムは行数、2番目のカラムは単語数、最後のカラムは文字数である。

https://ja.wikipedia.org/wiki/Wc_(UNIX)

実装としては GNU wc と BSD wc があるようだ。

どういう手順でやったか

以下のような手順を考えた。

  1. まず機能を満たす
  2. 実装を見直す
  3. ベンチマークする
  4. チューニングする
  5. 独自機能を足す

なぜそう考えたか、そして実際にやったことを書いていく。

1. まず機能を満たす

まずは汚いコードでもいいから、機能を満たすコードを書くことにした。それには以下の理由がある。

  • 最初からきれいに書こうとか、効率よく書こうと考えると手が止まってしまう
  • 現時点の実力を知る
    • 今の武器は A Tour of Go で学んだことだけ。これでどれだけ書けるかやってみたい
    • もっと知識を仕入れた後で改善するという楽しみを取っておきたい

で、主にこういうことをやった。

  • バイト数を数える
  • 単語数を数える
  • コードの整形(タブにするとか)
  • 複数ファイルの時に Total を出す
  • 出力フォーマットを wc に合わせる
  • コマンドライン引数のサポート

コードの整形だけちょっと違うけど、Go 言語では「インデントはタブ」など推奨されるフォーマットがあるので、これは早めに従っておきたかった。具体的には vim-go を使うようにした。今のところ特に設定はいじらずに使っている。とても便利。

それでこんな感じのコードができた。

go-wc/go-wc.go at 89cbb1dbc2d901d132ee1ad59e2b964a57aeb0f2 · takatoshiono/go-wc · GitHub

行数、バイト数、単語数を数えるのに何回もファイルから一気読みしたりして富豪的

2. 実装を見直す

それっぽい動きができてきたところで次のステップ。もうちょっとかっこいい実装にしたい。ちょうどいいことに みんなのGo言語【現場で使える実践テクニック】 が発売されたばかりだったので購入して以下の章を熟読した。

  • 第1章 Goによるチーム開発のはじめ方とコードを書く上での心得
  • 第3章 実用的なアプリケーションを作るために
  • 第4章 コマンドラインツールを作る

めっちゃ勉強になる。

それで以下のようなことをした。

  • 引数を io.Reader インターフェース型で受け付けることでファイルと標準入力を同じように扱う
  • 構造体、変数の名前の見直し
  • ディレクトリ構成の見直し
    • lib とか cmd ディレクトリを作るというやつ
    • まだコードの大幅変更がありそうなので保留にした。規模が大きくなるとファイル分割は有効だろうけど、今は練習だし1ファイルになっていた方が見通しが良いと考えたため

それでこういうコードになった。

go-wc/go-wc.go at cea7defc2cfe0d148f7df23fe6c6e104c68eedc2 · takatoshiono/go-wc · GitHub

3. ベンチマークする

そろそろ実行速度が気になってきた。Go 言語はそれなりに速いはずという期待があるので wc と比較したい。まずは計測から。 以下のような雑なシェルスクリプトを書いて time コマンドで計測することにした。

#!/bin/sh

COMMAND=$1
FILE=$2
MAX=$3
for i in `seq 1 $MAX`; do
    $COMMAND $FILE >/dev/null
done

計測内容

実行環境

コマンド

  • wc: Mac に入ってたやつ
  • go-wc: go install して $GOPATH/bin にできたやつ

計測対象のファイル

  • 小さいファイル (3.4KB)
    • ~/.vimrcを使った
  • 大きいファイル (416MB)
    • 手元にあった mysqldump のファイルを使った。1行がとても長い。

結果

小さいファイル (3.4KB)

1000回実行する。

wc コマンド

$ time ./bench.sh wc vimrc 1000
./bench.sh wc vimrc 1000  0.78s user 1.13s system 75% cpu 2.528 total
$ time ./bench.sh wc vimrc 1000
./bench.sh wc vimrc 1000  0.79s user 1.14s system 76% cpu 2.515 total
$ time ./bench.sh wc vimrc 1000
./bench.sh wc vimrc 1000  0.79s user 1.16s system 76% cpu 2.535 total

go-wc コマンド

$ time ./bench.sh go-wc vimrc 1000
./bench.sh go-wc vimrc 1000  0.77s user 2.53s system 65% cpu 5.051 total
$ time ./bench.sh go-wc vimrc 1000
./bench.sh go-wc vimrc 1000  0.77s user 2.52s system 65% cpu 5.049 total
$ time ./bench.sh go-wc vimrc 1000
./bench.sh go-wc vimrc 1000  0.78s user 2.53s system 65% cpu 5.063 total

wc の倍くらい遅い。

大きいファイル (416MB)

遅いので10回だけ実行する。

wc コマンド

$ time ./bench.sh wc dump.sql 10
./bench.sh wc dump.sql 10  17.29s user 0.67s system 99% cpu 18.040 total

go-wc コマンド

$ time ./bench.sh go-wc dump.sql 10
./bench.sh go-wc dump.sql 10  91.87s user 4.01s system 104% cpu 1:31.42 total

5倍くらい遅いし、実行中にメモリを900Mくらい使っていた。

4. チューニングする

ここから速くしていく。goroutineっていうの使うと速いんじゃないの?という心の声を無視して地道にやっていく。goroutineは使わないで最後にとっておく。

メモリ消費を減らす

メモリ消費量を少なくするために1バイトずつ読んでみたらもっと遅くなった。特に大きいファイルだと遅い。遅すぎて途中で実行中断した。

$ time ./bench.sh go-wc vimrc 1000
./bench.sh go-wc vimrc 1000  1.13s user 2.69s system 68% cpu 5.560 total
$ time ./bench.sh go-wc vimrc 1000
./bench.sh go-wc vimrc 1000  1.13s user 2.69s system 69% cpu 5.525 total
$ time ./bench.sh go-wc dump.sql 10
^C
./bench.sh go-wc dump.sql 10  515.87s user 5.46s system 107% cpu 8:02.82 total

コードはこれ。

go-wc/go-wc.go at cabe08af93dbf2363d869bf4c5b9cd2f8acb3bfa · takatoshiono/go-wc · GitHub

4KBずつ読む

4KBずつ読むようにしてみた。全然速くならない。

$ time ./bench.sh go-wc vimrc 1000
./bench.sh go-wc vimrc 1000  0.77s user 2.55s system 65% cpu 5.058 total
$ time ./bench.sh go-wc vimrc 1000
./bench.sh go-wc vimrc 1000  0.81s user 2.92s system 63% cpu 5.837 total
$ time ./bench.sh go-wc dump.sql 10
./bench.sh go-wc dump.sql 10  93.72s user 2.30s system 98% cpu 1:37.34 total

コード

go-wc/go-wc.go at fd6de5797cb1bc0f2911377a588402389011fe4a · takatoshiono/go-wc · GitHub

プロファイリング

どこが遅いかわからないのでプロファイリングしてみることに。pprofというのを使うらしい。

$ go tool pprof ~/bin/go-wc cpu.prof
Entering interactive mode (type "help" for commands)
(pprof) top20
9.13s of 9.23s total (98.92%)
Dropped 36 nodes (cum <= 0.05s)
Showing top 20 nodes out of 23 (cum >= 0.05s)
      flat  flat%   sum%        cum   cum%
     8.86s 95.99% 95.99%      8.86s 95.99%  syscall.Syscall
     0.09s  0.98% 96.97%      0.26s  2.82%  bytes.FieldsFunc
     0.07s  0.76% 97.72%      0.07s  0.76%  unicode/utf8.DecodeRune
     0.06s  0.65% 98.37%      0.06s  0.65%  unicode.IsSpace
     0.05s  0.54% 98.92%      0.05s  0.54%  runtime.deductSweepCredit
         0     0% 98.92%      8.86s 95.99%  bufio.(*Reader).Peek
         0     0% 98.92%      8.86s 95.99%  bufio.(*Reader).fill
         0     0% 98.92%      0.26s  2.82%  bytes.Fields

ほぼ syscall.Syscall で結局よくわからず。

困ったなー、とか言いながらコードをいじっているとどうやら単語数を数える処理が遅いようだということがわかった。単語数を全く数えないようにすると wc より速くなる。

単語数を数える処理の改善

これまでは何も考えずに bytes.Fields を使ってホワイトスペースで区切って len で長さをとって単語数としていた。

len(bytes.Fields(bytesRead))

bytes.Fieldsの実装を見ると (bytes パッケージのコード)

  1. ホワイトスペースで区切って数を数える
  2. その数のスライスを作って返す

ということをやってた。いま必要なのは 1. だけで、スライスはいらない。だから実装を真似して自分で数えることにした。

$ time ./bench.sh go-wc vimrc 1000
./bench.sh go-wc vimrc 1000  0.73s user 2.52s system 65% cpu 4.983 total
$ time ./bench.sh go-wc dump.sql 10
./bench.sh go-wc dump.sql 10  44.01s user 1.56s system 99% cpu 45.631 total

小さいファイルだと変わらないけど、大きいファイルだと倍くらい早い。よしよし。 でも wc より全然遅い。

コード

go-wc/go-wc.go at f998ed1e0a4d8463ad4baaf63b23e05aee00119c · takatoshiono/go-wc · GitHub

goroutine にする

もう少し見ていくと単語数を数える処理の中でも特に utf8.DecodeRune が遅いようだった。

これはバッファの先頭の UTF-8 文字を取得する関数。

wc の実装を見ると同じような処理に mbrtowc というのを使っていた。避けては通れなそう。

んで、結局「遅い処理を goroutine にしちゃえばいいじゃん」という考えに至り、試してみたところさらに倍くらい速くなった。

➜  bench-go-wc  time ./bench.sh go-wc vimrc 1000
./bench.sh go-wc vimrc 1000  0.76s user 2.82s system 65% cpu 5.429 total
➜  bench-go-wc  time ./bench.sh go-wc vimrc 1000
./bench.sh go-wc vimrc 1000  0.75s user 2.78s system 66% cpu 5.316 total
➜  bench-go-wc  time ./bench.sh go-wc dump.sql 10
./bench.sh go-wc dump.sql 10  79.22s user 2.29s system 350% cpu 23.275 total
➜  bench-go-wc  time ./bench.sh go-wc dump.sql 10
./bench.sh go-wc dump.sql 10  81.62s user 2.57s system 340% cpu 24.744 total

大きいファイル(dump.sql)では wc の結果に近づいてきた。

ただ、

  • 小さいファイルだとこの恩恵は受けれない
  • CPU をたくさん使う

というのがあるので、ズルしている感じはある。

コード

go-wc/go-wc.go at ecfe5944eae2be58e2d7cecb39eb9bf6b830955c · takatoshiono/go-wc · GitHub

5. 独自機能を足す

例えばファイルだけでなく URL を指定できる、というのを考えた。そうすると HTTP 通信するコードを書く練習もできる。 でもまだやってない。

まとめ

9/11 〜 9/21 の間でやっていた。朝、昼、夜の細切れの時間(最短で15分とか)でコツコツやってたけど、やっぱりコードを書くのは楽しくて、がんばって時間を捻出していた。やる気がある時はこのように時間を無理やり作り出すことができるし、継続もするのだなあということがわかった。

みんなのGo言語がなかったら、もっと時間がかかっていたと思う。とても参考になった。例えば bufio.Reader.Peekとか、goroutine の終了を sync.WaitGroup で待ち受けるコードなんかは、みんなのGo言語を読んでいたからこそすっと書けたコードなのでとても良かった。

自分でコードを書くというのが、ひとまずできたので、次はお手本となるようなコードを読んだり、写経したりしてみようかな、という気持ちでいます。

あとできることなら wc より速くしたい。

みんなのGo言語【現場で使える実践テクニック】

みんなのGo言語【現場で使える実践テクニック】