Goも当然にMapよりSwitchが速い ― 制約によるコンパイル時最適化の威力
MapはO(1) 、SwitchはO(N) ?
Goのmapは、なにか深遠なるアルゴリズムによって取得時
が成立していると聞いています。 O(1)
switchは単純に一つ一つの節にマッチするか検証しているからですよね? O(N)
だから、switchよりもmapを使ったほうがいいと思います
switchで十分実現できる処理がmapで書かれていたのでレビューで変えるように指摘すると、このようなことを言われました。
確かにmapは取得時
ここで、
しかし実際に計測してみるとswitchのほうが速いケースのほうが多いです。
なんてこった!時間計算量が裏切った!信じていたのに!
納得できないレビュイーのために、せっかくなのでコンパイラの実装も交えて見ていきましょう。
switchはO(N) ?
switch文をコンパイラがどう解釈するかは
の部分がわかりやすいでしょうか。
jump tableをまず最初に適用可能か調べて適用可能であれば適用し、そうでなければ二分探索を行うという記述です。
jump tableは機械語で言えば大体1命令であるようなもので、ざっくり表現するなら配列アクセスと同じような処理で条件分岐を実現します。
mapアクセスの
そして、適用外だった場合の二分探索ですが、
適用外になるには、jump table命令が使用できない[1]かjump table命令では非効率[2]かといったときですが、そうなったとしても
一応ですが、if文の列挙と同レベルまで落ちるケースもあります。 逆に言えばこれまでの話はif文の列挙よりもswitch文の方が速くなりやすいという話でもあったわけです。
総じて、switchは基本的にcase数に対して
MapはO(1) ?
翻ってGoのmapはどうでしょうか。
まず触れておかなければならないのは、mapが
ハッシュ値が衝突するなどの理由で、最悪計算量は
最良で
まず、mapを動かすためにはhash計算が必要で、これは比較的重い処理であるというのが一つ。
またmapは、SwissTableなどアルゴリズムの進展でだいぶそのようなことはなくなりましたが、メモリ局所性などの理由から二分探索
対してswitchが
言い換えれば
- 線形の計算量であってもNが小さいうちは定数項が支配的になりやすい (mapは定数項が重い、switchは定数項が軽い)
- switchが線形の計算量であるというのが誤解
ということで、冒頭のレビュイーは推測の出発点を間違えていたということになります。
このようなことから、実際にベンチマークを取ってみたときに、mapで書くよりもif文の連鎖のほうが速く、それよりさらにswitch文で書いたほうが速いというケースがほとんどになるのです。
結論としてはswitchが基本的に速度面で有利なので、分岐数が100を超えてきて初めてmapを検討するために計測を始めるくらいの向き合い方でいいと思いますが、そもそもswitchで分岐数が100を超えそうなときは設計を疑ったほうがいいかもしれません。
ベンチマーク
古いPC(Windows10, Intel 6700K)で全くクリーンではないので参考までに
| 実装タイプ | 小規模(8) | 中規模(100) | 大規模(1000) | 性能特性 |
|---|---|---|---|---|
| 整数リテラルSwitch | 2.47 ns/op | 6.83 ns/op | 12.40 ns/op | Jump Table |
| 整数Map | 11.32 ns/op | 21.10 ns/op | 18.71 ns/op | Swiss Table |
| 浮動小数点数リテラルSwitch | - | - | 17.30 ns/op | 二分探索 |
| 浮動小数点数Map | - | - | 37.45 ns/op | Swiss Table |
| 変数Switch | 0.59 ns/op | 11.74 ns/op | 285.1 ns/op | 線形探索 |
感想
ベンチマークは実際の環境で困ったときに測るほうがよっぽど有用だと思っているので補足程度に認識お願いします。
整数リテラルswitchが大規模になるにつれて遅くなっていますが、アセンブリを見てみた感じjump tableは使っていたのでキャッシュミスとかなんですかね。まぁ1000ケースのswitch書いたらまずIDEが重くなったので実用的にはどうでもいいといえばどうでもいい。
整数Mapが中規模と大規模で速さ変わらないのは
二分探索が別に遅くも早くもないのはまぁ1000程度でそんな差が出ることも望めないという感じ。二分探索のアセンブリも見れて満足。
線形探索でもmapよりswitchのほうが速いのはそれだけmapが重いのを示してますね。100程度だったらmapの
SwitchよりMapのほうが対応関係を示しているとわかりやすい?
MapはKeyとValueの対応を表すための構造を値として示せるじゃないですか
対してSwitchは制御構造だから、対応関係を表しているとわからないじゃないですか
だから、switchよりもmapを使ったほうが分かりやすくていいと思います
これもレビューで指摘したときに言われました。
switch v {
case 1:
return "good morning"
case 2:
return "good afternoon"
case 3:
return "good evening"
default:
return "hello"
}
という節内で即座に値をreturnしているswitchと、
map[int]string{
1: "good morning",
2: "good afternoon",
3: "good evening",
}
というMapを見比べたとき、Mapが単純に対応関係を書いているだけなのに対し、switchは余計な構文がついてしまっているように見えます。
それもそのはずで、switchは即座に値を返して対応関係を作る以外のこともできるわけで、節内で即座に値のreturnしかしていないということは全節を見るまでわからないわけです。
また、一行目だけ見てみますと、
switch v {
はswitch文なんだなーと思うだけ
map[int]string {
はKeyがintでValueがstringのMapなんだなとわかるわけです。
なるほど、確かに冒頭の話は間違いではなさそうです。
ですが、速度よりも可読性を重視するとしてもmapは良い選択ではありません。
先程のmapを関数を通じて提供してみましょう。
var greetingMap = map[int]string{
1: "good morning",
2: "good afternoon",
3: "good evening",
}
func Greeting(t int) string {
if v, ok := greetingMap[t]; ok {
return v
}
return "hello"
}
どうでしょうか?
まず、Greeting関数のインターフェースを知りたい人は、
func Greeting(t int) string
を見るので、
var greetingMap = map[int]string
という記述の重要さは小さくなりました。
そして、このGreeting関数がどういう対応関係を持つのかというのを把握するためには、greetingMapとGreeting関数のfallbackの分断された二箇所を見る必要が出てきたということもわかります。
さらに、mapは実行時に要素を変更することが可能なので、スコープ内でmapが変更される可能性を調べ上げなければその二箇所に書かれていることだけが真実だということも確定できないということも言えます。
これがswitchになれば
func Greeting(t int) string {
switch t {
case 1:
return "good morning"
case 2:
return "good afternoon"
case 3:
return "good evening"
default:
return "hello"
}
}
素晴らしい。mapの持っていた問題点はすべて解決し、インターフェースが一見してわかるという特徴はそのままです。
switchで新たに発生している問題といえば、構文が宣言的なものから手続き的なものに変化したことによって、やろうと思えばcaseの中で長い処理をベタ書きできるようになってしまったというのが思い浮かびますが……それはレビューで弾きましょう。
そんなことが起こり得る環境でswitchではなくmapであれば宣言的に書いてくれるはずという仮定も虚しいです。
逆に、基本的に純粋関数で書かれているプロジェクトなら、あまり気にしなくてもいいでしょう。
mapよりもswitchが当然に速いという感覚
記事冒頭のレビュイーの感覚に、いやいやとんでもない誤解だぞと、推測せずとも計測せずともswitchのほうが速いことを当然に期待するものじゃないかと思われた方々も多くいらっしゃると思います。
自分もそうで、最初からこの記事のように(特にGo言語において)完全な根拠を揃えて言語化してからmapではなくswitchを使えとレビューしたわけではありません。
switchのほうがmapより速くてわかりやすいでしょうというのを当然の感覚として持っていたわけです。
実際に計測すること、実際のコンパイラの実装を読むこと、実際に生成されるマシン語を読むこと、これらは確実ですがコストがかかります。
なにより、そもそもの感覚がなければ疑問にも思わず、計測も調査も行おうとすら思わないかもしれません。
コンパイラ最適化を信じているとか、ハッシュテーブル実装が重いことを経験的に知っているとか、そもそも条件式をベタ書きでいくら列挙しようが
静的に決定できるものは静的な形で書け
ということです。
mapはメモリ上に積まれる構造体であるわけで、要素の増減はもちろんのこと、宣言時や取得時にも動的な要素が絡みます。
これは、コンパイル時の最適化が効きにくいことを意味していて、速度的に不利な傾向があります。
また動的であるということはそれだけ不確実性が増すということで、コード上の処理の追いやすさにも影響します。
この原則はmap以外のことにも言えて、最も単純なものでは変数よりも定数を選べとか、動的型よりも静的型を選べ、ということにも通底しています。
静的に決定できるものを静的に書くことが、静的型付け言語を好む人に共通する基本的なマインドセットとも言いかえられます。
動的型付け言語の人々はまた違ったマインドセットを持っていたりもします[3]が、静的型付け言語ではこの教訓を持っていれば一つ指針になるのではないでしょうか。
Discussion
ベンチマークで「小規模」の
caseの数が8となっているのはテーブルジャンプに最適化される最少の条件に合わせているのだと思いますが記事も同様にされると良いのでは?caseの数が3ではテーブルジャンプも二分探索もされないと思うので出現頻度の順でifで判定した方が速いまでありそうです。この記事は
switch推しですがswitchによるテーブルジャンプがmapよりも効率良いとしてもテーブルジャンプはCPUのパイプラインをフラッシュするのでそんな良いものでもなく連番から対応する文字列を取得する程度の処理であれば配列やsliceを使った方が効率的だと思います。ベンチマークの
main_test.goに配列/sliceでの実装を加え、副作用のない関数の戻り値を_へ代入するのは最適化で関数の呼び出しが削除される可能性があるのでsinkへ代入する変更を加えてテストしてみました。Smallに対してMediumとLargeでのSwitchのパフォーマンスが低いので確認したところmediumSwitch()とlargeSwitch()のサイズが大きいためベンチマーク関数からの呼び出しでインライン展開されないことが原因のようです。main_test.go変更差分
コードレビューではコードの確からしさや保守性を考慮された書き方をされてるかが重要でありパフォーマンス改善はそれが問題となっている場合でなければ重要ではありません。
mapで十分実現できる処理をswitchに変えるよう指摘するのは適当ではないと思います。という主張は正当だし実際
mapの方が保守性が高くて良いでしょう。パフォーマンスが重要ということであれば配列やsliceへの変更が労力も小さくて済むので現実的ですね。そうですね。実際のところ、パフォーマンスは記事冒頭にも書いているようにどうでもいいです。
自分はmapよりもswitchのほうが保守性が高いと考えているので、パフォーマンスが悪いと考えてswitchよりもmapを選ぶのは正しくないというのが前段の趣旨です。
mapは動的にエントリーが増えていくことも含意している書き方ですので、静的に決定される値の対応関係というより狭義の構造を表すためには、それに特化しているswitchを選ぶべきだと考えています。
静的に解決できることによって、処理系にとっていいことがあると説明しているのが前段で、人間にとっていいことがあると説明しているのが後段ですね。
「自分はmapよりもswitchのほうが保守性が高いと考えているので」ということで主観による指摘なわけですが、レビュイーも
と主観による主張をされており、互いに言い分があるのであればレビュイーを納得させることは困難だと思います。
記事の脚注に「実際このレビュイーたちはJavaScriptの経験が主な人たちでした」とあることから察するにKinaさんはベテランでありレビュイーはそうではないのだと思います。レビューの場ではベテランは強い発言力を持つ場合が普通にあり、弱い側が納得をしてなくても指摘を受けざるを得ない状況ということは起こりうるでしょう。そうなっては禍根を残す原因にもなりそうですね。
主観による主張は平行線で解決は困難ですが社内やチームでのコーディングルールとして静的に書ける部分はそれを優先する等のルールが設けられていればそのようなストレスからは解放されます。指摘も「コーディングルールに従い~に修正してください」で済むこととなります。
コーディングルールを整えておけば無用な軋轢を生まないというのは、一つチーム運営上のテクニックですね。
「静的に書けるものは静的に書く」ルールを作る際に、それにはどのようなメリットが有るのかの理由を示す材料にこの記事を使ってもらえればと思います。
主観 vs.主観ではないかという部分について補足すると、このレビューはmapを関数を通じて提供した形のコードに対してのものであり、記事中でもその前提でswitchとmapの構文を比較しています。
「mapは対応関係を表す」に対して、関数で提供するのだから変わらないというレビューコメントですね。
これだけならどっちもどっちの主観の問題になっただけですが、さらにswitchとmapの構文によって
という特徴があるために、switchを選ぶことに優位性が出てくるということを記事では解説しています。
「自分の考えでは」とつけたのは、保守性が高いとまで言うためには記事中の論点が全部どうでも良いとは思わないという前提が必要なため、前提の共有のない記事の読者に向けてです。
記事に挙げた特性のいずれも重視しない立場でもイーブンになるだけなので、構文に優位性があるということそれ自体は主観の問題ではありません。
ルール化は重要ですが、なぜそのルールになっているのか技術的に説明することで(立場の差がそれを難しくするとしても)建設的な議論が成り立つということも重要だと思います。
この記事は表題から内容までかなりの部分を実行パフォーマンスについて説明されていると思いますが、重要なのが静的に書くということであれば全体の見直しをされると良いと思います。