自作アプリのパフォーマンス改善

この記事は 弥生 Advent Calendar 2020 の9日目です。

はじめに

こんにちは。 寒さにやられて体調不良な日々を過ごしていたりいなかったりな私です😷

そんな体調不良な日々の中、Rustの勉強のために自作アプリをRustで書き直していたのですが、処理が遅くなるという悲しい(けどありがちな)事象が発生。

そのままにするのも嫌だったのでパフォーマンス改善を試みてました。

この記事では改善作業について書きました。

誰かの参考になると嬉しいなぁ。

「遅い」

「遅い」は相対的な言葉ですよね。

今回は自作アプリを書き直す前後で、同じ処理をさせたときにかかる時間を計測して差を測りました。

基準は「書き直す前」のアプリ(昨年作成したBrainF***処理系)で、mandelbrot.rsを動かしたときの時間を測ってます。

BrainF***がわからない、というかたはwikipediaのページをどうぞ。

ちなみに実行結果はこうなります。ほんといつみてもカッコいい。 f:id:ryotaway:20201209013242p:plain

計測にはtimeコマンドを使いました。

計測した環境は↓

結果は↓

No. バージョン 処理時間 [s]
1 C版
27.09
2 Rust版(書直しVer)
34.92

8秒くらい遅くなってる😩

仮説と改善、そして検証

「遅い」は「悪い」。原因について検討し、↓の仮説を立てました。

  1. 無駄な処理がある
  2. 言語仕様上、遅くなる処理がある

1. 無駄な処理がある

まず1. については、'[' と ']'のペアを探す処理が無駄だなーと昨年C言語版を開発した時から気になっていたので、そこを解消しようと思いました。

気になってたのはこのwhile()でグリングリン回してるところ

改善として、

としてみました。

2. 言語仕様上、遅くなる処理がある

次に2. についてですが、BrainF***のコードを眺めていると

+++

のように繰り返し同じ命令が登場するのが気になりました。 BrainF***では1つの命令で+1, -1などができるだけで、まとめて+10とかはできません。なので繰り返し同じ命令が登場しやすくなるのですが…。

また、各命令の総実行回数も気になりました。どのくらいなのかな。

ということでまずは計測。

mandelbrot.bfを動かしたときの、各命令の総実行回数は↓となりました。

命令 実行回数
>
4,453,036,023
<
4,453,036,013
+
179,053,599
-
177,623,022
.
6,240
,
0
[
422,534,152
]
835,818,921

冗談みたいな数字が並んでますが、あってるっぽい😩

'>' は44億回以上実行されてるし、'+'は1億8千万回くらい実行されているようです。

例えば'.' は文字を出力する命令なんですが、
mandelbrot.bfでは、画面表示は1行につき
    129文字+改行1文字 = 130文字
表示されます。
それが48行出力されるので、
    130*48 = 6420
となります。

計測は命令を処理するときにカウントするコードを仕込んだだけです。簡単🙃

本当は処理時間の計測をできるようにするつもりだったんですが、
結局作業の最後までやりませんでした。
なので Profiler という構造体名になってますが名前負けしてますね。
責務としてはせいぜいがCounterといった程度です。

まぁとにかく、処理する命令数が結構多いなぁと思ったので命令の圧縮を考えるのがよさそう、ということがわかりました。

というわけで改善のためコンパイラ(というほど大仰なものではない)を作りました。

やってることは単純で、

  1. 命令を、種類と回数の組みと定義する
  2. いくつかの命令については連続したものをまとめて一つの命令にする

ということをしているだけです。

改善の結果

結果を表.1にまとめました。

表.1

No. バージョン 処理時間 [s]
1 C版
27.09
2 Rust版(書直しVer)
34.92
3 Rust版(無駄を省いたVer)
37.32
4 Rust版(コンパイラ導入Ver)
14.04
5 Rust版(無駄を省いた&コンパイラ導入Ver)
23.07

🤔

結果の検討

表.1 のNo.3はNo.2よりも遅くなってました。

No.3の改善では実装にHashMapを使ったのですが、このHashMapのアクセスにかかる時間より'['と']'を一文字ずつサーチする方が速い、ということを意味してます。多分。 そんなことある?って思ってるので、処理がバグってるんじゃないかとも疑ってますが。

No.4の命令をまとめるという改善はだいぶ効きましたね。 処理時間がC版の50%近くまでになってます。mandelbrot.bfは処理する命令の回数が非常に多いので、連続した命令をまとめて1回で処理する、という方針は効果的だったんですね。これは嬉しい結果です🤗

次に処理時間が速かったのはNo.5ですが、これは単にコンパイラを導入して早くなったけどHashMapを使った部分が足を引っ張っているだけに見えます。組み合わせてもあまり意味はありませんでしたね。

まとめ

当たり前の結論になりましたが、

  • 原因を頭で考えるよりも実際の計測結果の方がボトルネックを調べるときはあてになる(なった)
  • 処理される命令の数(≠プログラム上の命令の数)を把握するのってボトルネック調べるのに大事

あたりが、今回得られた知見でしょうか。

実は↓を実装したら速度改善するか、という疑問がまだ残っていますが、まぁそれはまた来年にでも。

  • プログラム自体の最適化
  • 頻出のイディオム( [-] など)を1命令で実行できるようにする
  • JITコンパイル
  • 処理の依存関係の発見と並列化処理

なお、今回の最終的なコードは以下にあります。

github.com

ご参考まで。

それでは、また👋