/note/tech

"Clean" Code, Horrible Performance

要約:

■ 1. クリーンコード規則の検証方針

  • 「クリーン」コードという助言について:
    • 初心者に向けてよく勧められる助言であり、「クリーン」であるための多数の規則を伴う
    • 規則の大部分は実行時の挙動に影響しないため客観的な評価はできない
    • しかし規則の一部は実行時性能に直接影響するため、客観的に測定可能である
  • コード構造に影響する5つの規則:
    • if/elseやswitchよりポリモーフィズムを優先する
    • コードはオブジェクトの内部を知るべきではない
    • 関数は小さくあるべき
    • 関数は一つのことだけをするべき
    • DRY(繰り返しを避ける)
  • 検証方針:
    • クリーンコード文献で実際に使われている例示コードをそのまま用いる
    • これによりクリーンコードにとって最も有利な条件を作った上で、その主張者自身の規則を検証する

■ 2. ポリモーフィズムとswitch文の比較

  • クリーンコード版の実装:
    • shape_baseを基底クラスとし、square/rectangle/triangle/circleがこれを継承する構造にする
    • 各派生クラスは仮想関数Area()を持ち、自身の面積計算に必要なデータも自身で保持する
  • 面積合計ループの実装:
    • shape_baseへのポインタ配列を受け取り、各要素のArea()を呼び出して累積するTotalAreaVTBLを用意した
    • クラス階層である以上、図形ごとのメモリサイズが不明なためポインタ配列にせざるを得ない
    • イテレータは規則に明記されていないため使用せず、コンパイラを混乱させる要因を排除した
    • 累積のループ依存を疑い、4つのアキュムレータに手動アンロールした版も念のため用意した
  • 計測方法:
    • コールドキャッシュ状態(L3にはあるがL1/L2はフラッシュ済み、分岐予測器も未学習)での1回実行を計測した
    • キャッシュと分岐予測が最も有利に働く条件での繰り返し実行も計測した
    • 両者の結果に大きな差はなかった
  • クリーンコード版の結果:
    • 1図形あたり約35サイクル(条件次第で34サイクル程度)を要した
  • switch文版への書き換え:
    • enumのshape_typeと、Width/Heightを持つ単一構造体shape_unionへ全図形を統合した
    • GetAreaSwitch関数内のswitch文で各図形の面積を計算する、クリーンコード以前の書き方にした
    • 専用フィールドを持たないため、高さを使わない図形はWidthのみを参照する
    • 合計ループの中身はクラス階層版とほぼ同一であり、仮想関数呼び出しが通常の関数呼び出しに変わっただけである
  • switch文版の構造的な利点:
    • 図形が同一サイズの構造体になるため、ポインタではなく配列に直接格納できる
    • コンパイラがGetAreaSwitch関数の全コードパスを静的に把握できるようになる
    • 実行時にしか分からない仮想関数を仮定する必要がなくなる
  • switch文版の結果:
    • 1図形あたり24サイクルとなり、クラス階層版に対して1.5倍高速だった
    • この差はiPhone 14 Pro MaxからiPhone 11 Pro Maxへ後退するのに等しい規模である
    • ハードウェアの進化にして3〜4年分が失われる計算になる

■ 3. 内部知識を活用した最適化

  • 共通パターンの発見:
    • switch文の各caseは、いずれも「幅×高さ」または「幅×幅」に係数を掛ける構造になっている
    • 係数は三角形なら0.5、円ならπというように、図形ごとに定数として表せる
  • switch文の意義:
    • 操作(関数)単位でコードがまとまっていると、こうした共通パターンを見つけやすい
    • クラス階層版では型ごとにコードが分割され、さらに別ファイルに分けるのが慣習であるためパターンに気づきにくい
    • 同種の型は似た構造を持つことが多く、このパターンの発見は特別な例を選んだ結果ではない
  • テーブル駆動方式への書き換え:
    • 図形種別ごとの係数を格納したテーブルCTableを用意する
    • 単一パラメータの図形(正方形・円)ではWidthをHeightにも複製しておく
    • これによりGetAreaUnion関数はテーブル参照を含む1行の計算に単純化される
    • 合計ループ自体は変更不要で、呼び出す関数をGetAreaUnionに差し替えるだけでよい
  • 結果:
    • 1図形あたり3.0〜3.5サイクルとなり、クリーンコード版に対し10倍以上高速だった
    • コード量・トークン数・演算数も同時に減少しており、高速化と意味的単純化を両立している
    • 現行ベンチマークで遡れる最古の機種であるiPhone6と14 Pro Maxの性能差ですら約3倍にとどまり、10倍という差はiPhoneでは表現できない
    • デスクトップCPUで換算すると、2023年当時の平均性能から2010年当時の平均性能まで後退する規模に相当する

■ 4. プロパティ追加時の検証

  • 問題の拡張:
    • 各図形にCornerCount()という仮想関数を追加した
    • 四角形は4、三角形は3、円は0を返すようにした
    • 面積の単純合計ではなく「1÷(1+角の数)」を重みとした角数加重面積の合計を新たな計算対象とした
    • この拡張に特別な意図はなく、単に最も単純な形で複雑さを一つ足しただけである
  • 各版の更新内容:
    • クラス階層版はArea()に加えCornerCount()も呼び出し、係数を掛けて累積するCornerAreaVTBLとした
    • switch文版はGetCornerCountSwitchという新たなswitch文を追加し、GetAreaSwitchと組み合わせて計算する
    • テーブル駆動版はCTableの値に角数加重済みの係数を直接埋め込むだけでよく、コード自体は変更不要だった
  • 結果:
    • switch文版はクラス階層版に対して約2倍高速になった
    • テーブル駆動版は約15倍高速になった
    • プロパティを一つ増やしただけで性能差がさらに拡大した
    • 問題が複雑になるほどクリーンコードの手法が及ぼす害は大きくなる
    • ハードウェア換算では2023年の性能から2008年の性能まで後退する規模であり、12年分の後退が14年分にまで拡大した

■ 5. コンパイラ最適化と保守性への影響

  • クリーンコードとコンパイラの関係:
    • クリーンコードを徹底するほどコードは別々の翻訳単位や仮想関数呼び出しの背後に分散する
    • その結果、コンパイラがコード全体を把握できなくなり、最適化の余地が失われる
  • 保守性への影響:
    • 型を中心に設計されたコードベースでは、テーブル化やswitch文除去のような単純な改善すら困難になる
    • 場合によっては大規模な書き換えなしには不可能になる
    • 一方、関数(操作)を中心に設計されたコードベースでは、こうした改善が容易に行える

■ 6. AVX最適化版との比較

  • ここまでの比較はループ内累積依存の排除以外、いかなる最適化も行っていない状態のものである
  • 軽くAVX最適化を施した版と比較すると、性能差は20〜25倍に達する
  • このAVX最適化版はクリーンコードの原則を一切採用していない

■ 7. DRY原則への見解

  • DRY原則への基本的な立場:
    • 概ね同意できる規則である
    • 検証で用いたコードでもほとんど重複は生じていない
    • 唯一該当しうる4アキュムレータ版の重複は計測目的であり、通常は両方の関数を用意する必要はない
  • 厳格な解釈への留保:
    • 「同じ係数を符号化する複数のテーブルを作ってはいけない」という水準まで厳格に解釈するなら同意しかねる場合がある
    • 妥当な性能を得るためにはそうした重複が必要になることがあるため
  • 妥当な解釈での結論:
    • DRYが単に「全く同じコードを二度書かない」という意味であれば妥当な助言である
    • かつ性能を犠牲にせずに従うことができる

■ 8. 結論

  • 五規則の総評:
    • 構造に影響する5つの規則のうち、検討の余地があるのはDRYの1つのみである
    • 残る4つの規則については明確に避けるべきである
  • ソフトウェアが遅い一因:
    • 現代のソフトウェアはハードウェアの実力に比して低速である
    • その要因の一つが「クリーン」コードの実践である
  • クリーンコード規則の目的と代償:
    • 各規則は、より保守しやすいコードベースを作る目的で生まれたものである
    • しかしその効果を得る代償が10年以上のハードウェア進化に相当する性能であるなら、目的に見合わない
  • ソフトウェアの役目:
    • 与えられたハードウェア上で適切に動作することがソフトウェアの役目である
    • プログラマの労力を多少軽減するために10年以上のハードウェア性能を犠牲にすることは許容できない
  • 今後の規則のあり方:
    • コードを整理し保守しやすく読みやすくするための指針を模索すること自体は否定しない
    • しかしこれらのクリーンコード規則はその指針として不適切である
    • 「従うと性能が15倍以上低下する」という警告が伴わない限り、今後語られるべきではない

MEMO: