- Optunaというハイパーパラメータ最適化ツールを使って、RocksDB(組み込みDB・KVS)のパフォーマンスチューニングを試してみた際の結果メモ
- 対象となるワークロードに対して、最適な性能を発揮するパラメータ群を自動で見つけ出すのが目的
- 結果としては、デフォルトパラメータをそのまま使った場合に比べて、かなり良い性能が得られるパラメータ群を見つけることができた:
- デフォルトでのベンチマークの所要時間: 372秒
- Optunaによる最適化後のパラメータでの所要時間: 30秒
- RocksDBには、カスタマイズできるパラメータ群が多数ある(数十~数百?)
- 自分の利用用途に最適なパラメータ群を人手で調べるのは結構大変
- RocksDBは、チューニングガイドを含めて、かなりドキュメントが充実しているが、それでも不慣れな人には敷居が高い
- ↑のような状況は、RocksDBに限らず、ある程度以上規模が大きく歴史が長いミドルウェアだと珍しくない(おそらく)
- ※ 自分自身が開発者の一人だったり、長年携わっていて習熟度が高い場合は別
- こういった場合、通常は、以下のような流れとなることが多い印象がある(自分が不慣れなミドルウェアの場合):
- (1) まずはデフォルト設定で使ってみる
- (2) それで性能に問題があったら、公式ドキュメントのチューニングガイドを読むなり、Googleで検索するなりして、有名どころのパラメータを調整してみる
- (3) それでも性能が要求に満たないなら、公式ドキュメントその他を精読し、条件をいろいろと変えつつベンチマークを試行錯誤する
- (4) デプロイ先の環境が変わったら、それに合わせてまた再調整・再試行
- => 結構手間なことに加えて、最終的に落ち着いたパラメータ群が最適答かどうかも不明(幅広い組み合わせを試すのはたいていは時間的に無理)
- この辺りのプロセスを極力自動化して、手軽に最適な(ものに近い)パラメータ群を得られるようにしたい、というのがモチベーション
- とはいえこれはあくまでも理想であって今回は、Optunaがこういった用途に適用可能かどうかを軽く試してみただけ
- ハイパーパラメータ最適化ツール: optuna-v0.6.0
- ベンチマークツール: ekvsb-v0.0.12
- 内部的にはRocksDBのRustバインディングのバージョンv0.11.0を使用している
今回は、以下のようなかなり単純なワークロードを採用した:
- (1) 50万件分のエントリを保存(PUT)する
- 値のサイズは10KBに固定 (KVSとしては若干大きめのサイズ)
- 合計サイズ(論理)は5GB程度
- (2) 上記50万件分のエントリを順不同で取得(GET)する
- (3) 上記50万件分のエントリを順不同で削除(DELETE)する
上記の処理を行うのに適したRocksDBのパラメータ群をOptunaを使って見つけるのが目的。 なお、保存先デバイスにはHDDを使用(RocksDBはデフォルトではSSD向きにチューニングされているので、HDDとは相性が良くない)。
RocksDBのRustバインディングが提供しているオプション群は基本的に全てチューニング対象にしている(明らかに趣旨から外れるものは例外; e.g., enable_statistics
, set_wal_dir
)。
具体的には、以下の通り:
$ ekvsb run rocksdb -h
ekvsb-run-rocksdb 0.0.12
USAGE:
ekvsb run rocksdb [FLAGS] [OPTIONS] <dir>
FLAGS:
--block-opt-bloom-filter-block-based
--block-opt-cache-index-and-filter-blocks
--block-opt-disable-cache
--disable-advise-random-on-open
--disable-auto-compactions
--disable-concurrent-memtable-write
--memtable-factory-vector
--use-direct-io-for-flush-and-compaction
--use-direct-reads
--use-fsync
OPTIONS:
--block-opt-block-size <block-opt-block-size>
--block-opt-bloom-filter-bits-per-key <block-opt-bloom-filter-bits-per-key>
--block-opt-index-type <block-opt-index-type>
--block-opt-lru-cache <block-opt-lru-cache>
--bytes-per-sync <bytes-per-sync>
--compaction-readahead-size <compaction-readahead-size>
--compaction-style <compaction-style> [possible values: Level, Universal, Fifo]
--level-zero-file-num-compaction-trigger <level-zero-file-num-compaction-trigger>
--level-zero-slowdown-writes-trigger <level-zero-slowdown-writes-trigger>
--level-zero-stop-writes-trigger <level-zero-stop-writes-trigger>
--max-background-compactions <max-background-compactions>
--max-background-flushes <max-background-flushes>
--max-bytes-for-level-base <max-bytes-for-level-base>
--max-bytes-for-level-multiplier <max-bytes-for-level-multiplier>
--max-manifest-file-size <max-manifest-file-size>
--max-write-buffer-number <max-write-buffer-number>
--memtable-factory-hashlinklist-bucket-count <memtable-factory-hashlinklist-bucket-count>
--memtable-factory-hashskiplist-branching-factor <memtable-factory-hashskiplist-branching-factor>
--memtable-factory-hashskiplist-bucket-count <memtable-factory-hashskiplist-bucket-count>
--memtable-factory-hashskiplist-height <memtable-factory-hashskiplist-height>
--memtable-prefix-bloom-ratio <memtable-prefix-bloom-ratio>
--min-write-buffer-number <min-write-buffer-number>
--min-write-buffer-number-to-merge <min-write-buffer-number-to-merge>
--num-levels <num-levels>
--optimize-for-point-lookup <optimize-for-point-lookup>
--optimize-level-style-compaction <optimize-level-style-compaction>
--parallelism <parallelism>
--table-cache-num-shard-bits <table-cache-num-shard-bits>
--target-file-size-base <target-file-size-base>
--write-buffer-size <write-buffer-size>
Rustバインディングが本家の全てのパラメータを公開している訳ではないので、RocksDBを直接使う場合に比べるとかなり数は少なくなっているが、それでも40個程度のパラメータが存在している。
詳細は実際のスクリプト(optimize-rocksdb.py)を見て貰った方が正確、かつ、そこまで変わった使い方はしていないので、ここでは特記事項だけを記載:
- SuccessiveHalvingPrunerによる枝刈り:
- Optunaの各トライアルで、毎回、500,000件分のPUT/GET/DELETEをフルセットで実行していては時間が掛かってしまうので、見込みがないものに関しては早めに枝刈りするようにしている
- 具体的には、一回のトライアルで「5,000件分、50,000件分、500,000件分」の三パターンに対して、件数が少ない順にベンチマークを実行し、それぞれの終了時点で枝刈り判定が行われる
- トライアル全体のおよそ九割は最初の5,000件分の段階で捨てられることになる
- また、デフォルトパラメータの場合に比べて、極端に実行時間が長い場合にも途中で枝刈り(タイムアウト)が発生するにした
- サンプラーにはRandomSamplerを使用
- デフォルトのTPESamplerだと、今回のように枝刈りが多数発生する場合には相性が悪いことがあるため (optuna#270)
- 最適化は四時間実行する
# ワークロードファイルを生成
$ ./gen-workload.sh
$ ls workload.*.json
workload.500000.json workload.50000.json workload.5000.json
# パラメータ最適化&結果
$ python optimize-rocksdb.py
Study statistics:
Number of finished trials: 938
Number of pruned trials: 848
Number of timeout trials: 88
Number of complete trials: 2
Best trial:
Value: 29.064814499810876
Params:
block-opt-cache-index-and-filter-blocks: 1
max-write-buffer-number: 32
memtable-prefix-bloom-ratio: 0.921874416314259
bytes-per-sync: 21803784
table-cache-num-shard-bits: 11
use-direct-reads: 1
block-opt-bloom-filter-bits-per-key: 58
block-opt-index-type: HashSearch
compaction-style: Fifo
level-zero-stop-writes-trigger: 40
level-zero-file-num-compaction-trigger: 11
use-direct-io-for-flush-and-compaction: 0
memtable-factory: hashlinklist
block-opt-block-size: 30460927
memtable-factory-hashlinklist-bucket-count: 1836285
optimize-for-point-lookup: 1783
compaction-readahead-size: 14840331
target-file-size-base: 197272531
parallelism: 4
max-background-flushes: 2
disable-advise-random-on-open: 0
min-write-buffer-number-to-merge: 24
max-bytes-for-level-base: 671227089
use-fsync: 1
max-bytes-for-level-multiplier: 24.112568220334577
block-opt-disable-cache: 0
min-write-buffer-number: 5
level-zero-slowdown-writes-trigger: 54
block-opt-bloom-filter-block-based: 1
block-opt-lru-cache: 30779059
max-background-compactions: 8
num-levels: 24
disable-auto-compactions: 1
write-buffer-size: 253269056
総トライアル数 | 完走数 | タイムアウト数 | 枝刈り数 |
---|---|---|---|
938 | 2 | 88 | 848 |
上記は、トライアルの枝刈り数等を表にまとめたものだが、これをみるとトライアルのほとんどが枝刈りされていることが分かる。
なお、枝刈りを無効にして最適化を行った場合には、同じ時間で実行できたトライアル数は39
であった(その内の35
個は途中でタイムアウト。なおタイムアウトも無くした場合には時間内に完了したトライアル数は2
個までに減った)。
デフォルトと最適化後のパラメータでのベンチマーク結果の比較:
# デフォルトパラメータでベンチマークを実行
$ cat workload.500000.json | ekvsb run rocksdb /tmp/rocksdb-default/ | ekvsb summary
{
"oks": 1500000,
"errors": 0,
"existence": {
"exists": 500000,
"absents": 0,
"unknowns": 1000000
},
"elapsed": 373.72465610034095, # 所要時間(秒)
"ops": 4013.65009109077, # 秒間処理操作数
"latency": { # 各操作のレイテンシ(秒)
"min": 2.9e-6,
"median": 0.0000324,
"p95": 0.0004482,
"p99": 0.0016204,
"max": 3.8960246
}
}
# Optunaで最適化されたパラメータでベンチマークを実行
$ cat workload.500000.json | ekvsb run rocksdb /tmp/rocksdb-default/ --block-opt-cache-index-and-filter-blocks --max-write-buffer-number=32 --memtable-prefix-bloom-ratio=0.921874416314259 --bytes-per-sync=21803784 --table-cache-num-shard-bits=11 --use-direct-reads --block-opt-bloom-filter-bits-per-key=58 --block-opt-index-type=HashSearch --compaction-style=Fifo --level-zero-stop-writes-trigger=40 --level-zero-file-num-compaction-trigger=11 --block-opt-block-size=30460927 --memtable-factory-hashlinklist-bucket-count=1836285 --optimize-for-point-lookup=1783 --compaction-readahead-size=14840331 --target-file-size-base=197272531 --parallelism=4 --max-background-flushes=2 --min-write-buffer-number-to-merge=24 --max-bytes-for-level-base=671227089 --use-fsync --max-bytes-for-level-multiplier=24.112568220334577 --min-write-buffer-number=5 --level-zero-slowdown-writes-trigger=54 --block-opt-bloom-filter-block-based --block-opt-lru-cache=30779059 --max-background-compactions=8 --num-levels=24 --disable-auto-compactions --write-buffer-size=253269056 | ekvsb summary
{
"oks": 1500000,
"errors": 0,
"existence": {
"exists": 500000,
"absents": 0,
"unknowns": 1000000
},
"elapsed": 29.651524699936886,
"ops": 50587.617843584034,
"latency": {
"min": 2e-6,
"median": 0.0000112,
"p95": 0.0000424,
"p99": 0.000055,
"max": 0.4047035
}
}
Optunaによる最適化によって、所要時間が1/10程度になっていることが分かる。
以下の図は、各操作の所要時間(レイテンシ)をプロットしたもの(左=デフォルト、右=Optuna):
- 枝刈り便利
- パラメータの組み合わせによっては、平気で一桁二桁実行性能に差が出ることがあったけど、遅い場合でも枝刈りで早めに捨てられたので、最適化全体の実行時間に与える影響が少なくできて良かった
- Optunaに「どのパラメータの影響が大きいか(重要か)」を出してくれる機能があると便利
- それがあれば、とりあえず最初は指定可能なパラメータを全て指定しておいて、後で徐々に絞り込んでいく、といったことが出来そう
- 後は「影響度が高いものだけを人手でちゃんと調査する」とか「最終的に採用するパラメータ値から影響度が低いものは除外する」といったこともしたい
- 適切なワークロードを設定可能かどうかが鍵なイメージ
- 誤った対象に最適化しても仕方がないので
- ワークロードを上手く設定できて、ベンチマークを機械的に実行する仕組みがあるなら、結構使えそう
- 今回は使わなかったが、studyでDBをストレージのバックエンドとして使用する癖をつけた方が良さそう
- 後で結果を確認したり再開したり、といったことが手軽にできるので
- 特に最初の試行錯誤段階だと最適化(
study.optimize(...)
)を途中で中断したくなることも良くある
- 特に最初の試行錯誤段階だと最適化(
- ただし、オンメモリのストレージに比べて、SQLiteのストレージは三倍程度遅かった(手元の環境=WSLが悪い可能性もある)
- 後で結果を確認したり再開したり、といったことが手軽にできるので
- 実際の用途では、対象ミドルウェア以外(e.g., OS、ベンチマーククライアント)のパラメータ等をチューニングした方が良い場合もあるので要注意
- 最適化対象のパラメータ数が多いと、良い解を得られるまでは結構長い
- 今回は四時間回したけれど、もっとパラメータを多くしたら、これでも足りなさそうな印象
- 今回のワークロードはかなり簡単かつ人為的なものなので、より実践的なものにした場合にどのような結果になるのかは気になる
- 今回のようなwallclock-timeを指標にするパフォーマンスチューニングの場合には、対象ミドルウェアに指定してパラメータとは関係のないところで、性能が変化することもあるのが面倒
- 例えば、今回は最初はGCP上の仮想マシンを使って測定を行っていたら、時間経過に伴い明らかにディスクI/Oのスループットが(1/10以下に)下がっていったり、バラつきが大きくなったりして、複数トライアル間で得られた結果をまともに比較できる状態ではなかった(そのため最終的にはローカルマシンを使うようにした)
- ↑ほど極端ではないとしても、使用しているOSやファイルシステムの特性によって、トライアルの実行タイミング依存で性能が変わる可能性があることは念頭においておく必要がある(理想を言えば、チューニング対象パラメータ以外の要因による性能変化は完全に排除すべきだけど、現実的にはなかなか難しい)