/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倍以上低下する」という警告が伴わない限り、今後語られるべきではない

論評:

■ 1. Muratoriの主張

  • 比較対象:
    • "クリーンコード"の代表的ルール(ポリモーフィズム優先、内部知識の隠蔽、小さな関数、DRYなど)に従った図形クラスの面積計算コードと、switch文+テーブル駆動で書いた同等コードを比較
  • 結果と結論:
    • switch+テーブル駆動版が最大15〜25倍高速だったと報告
    • クリーンコードは性能面で許容できないと結論づけている

■ 2. サンプル選定はストローマン

  • 例の出典:
    • Muratoriはクリーンコード文献の例をそのまま用いたと主張するが、これはむしろ問題を悪化させている
  • 教育用トイ例の転用:
    • 図形の面積計算は、教科書がポリモーフィズムの概念を教えるために選んだ最も単純な例
    • 実務でホットループを毎フレーム数百万回回すコードでは、経験のあるエンジニアは最初からデータ指向設計(DOD)やテーブル駆動を選ぶ
    • 教育用のトイ例を性能クリティカルなベンチマークとして扱うのは藁人形論法の典型
  • 弁明の欺瞞性:
    • クリーンコード提唱者が使う例だから公正だという弁明は、その例が性能検証のために作られていないという事実を隠蔽している

■ 3. クリーンコード定義の恣意性

  • 定義の絞り込み:
    • 記事は「クリーンコードのルールのうち構造に影響するもの」を5つに絞り込み、そのうち4つを駄目と断じる
  • Robert C. Martinの文脈の省略:
    • Martin自身、パフォーマンスクリティカルな内側ループでの最適化を否定していない
    • まず動くコードを書き、それから最適化するというアプローチを推奨している
    • クリーンコードはホットパス以外、アプリケーションの95%を占める非クリティカルなコードに適用すべきという文脈が省略される
  • 小関数とインライン化の混同:
    • 「関数は小さくあるべき」「一つのことをする」というルールと、実行時のインライン化・コンパイラ最適化は本質的に矛盾しない
    • 現代のコンパイラは小さな関数を積極的にインライン展開する
    • 性能問題の主因はポリモーフィズム(仮想関数)であり、関数が小さいこと自体ではない
    • この2つを同列にクリーンコードのルールとして扱い、同じベンチマークの犠牲にしているのは論理のすり替え

■ 4. 交絡変数の未分離

  • 複数変数の同時変更:
    • 比較対象の「クリーン」版と「switch」版は、同時に複数の変数を変えている
      • vtable/仮想関数呼び出し対直接呼び出し
      • ポインタ配列(AoS of pointers)対値のフラット配列(実質AoS→SoAに近い形)
      • ヒープ上に分散したオブジェクト対連続メモリ上の構造体配列
  • 要因の混同:
    • 間接参照コスト、メモリレイアウト・キャッシュ局所性、分岐予測という異なる要因が一つの数字に混ざっている
    • 「ポリモーフィズムが1.5倍遅い」という主張は、実際にはポリモーフィズムによって強制されるポインタ間接参照とメモリ分散が原因である可能性が高い
    • ポリモーフィズムそのものの本質的コストとは言えない
  • 反証の余地:
    • タグ付きunion+関数ポインタテーブルなど、値型でvtableを使わない仮想関数もどきであればもっと近い性能が出せるケースもある
    • これを切り分けずにポリモーフィズムが悪いと結論づけるのは、統計的にも工学的にも粗い

■ 5. テーブル駆動化のミスリード

  • 数字の実体:
    • テーブル駆動版の10〜15倍という数字は記事内で最もセンセーショナルだが、実際には計算そのものをテーブルルックアップに置き換えて分岐や乗算を削減した効果
    • ポリモーフィズムを避けたことの効果ではない
  • 論点のすり替え:
    • これはクリーンコード対アンチクリーンコードの対決ではなく、アルゴリズムの選択の話に近い
    • クリーンコードの原則(内部知識を隠すなというルールへの違反)とテーブル駆動最適化は独立した最適化技法
    • ポリモーフィズムを使ったまま(仮想関数の中でテーブルを引く実装)でも一定の高速化は可能
    • この部分をクリーンコード対非クリーンコードの物語に組み込むのは比較の公平性を損なう

■ 6. 外的妥当性の欠如

  • 処理規模の小ささ:
    • テストされているのは35サイクル対3サイクルという、絶対値として極めて小さい処理単位
    • 実アプリケーションでこの関数がボトルネックになる比率、呼び出し頻度、I/Oや他の処理との比較への言及が一切ない
    • Amdahlの法則的な視点、全体のうちこの処理が何%を占めるかという視点が完全に欠落している
  • レトリックの誇張:
    • 「iPhone 14 Pro MaxからiPhone 11 Pro Maxに退化」「12年分のハードウェア進化が消える」という比喩は劇的だが、相対倍率をハードウェア世代に強引にマッピングしただけの修辞
    • 実際のユーザー体験や実務上のコストとは無関係な誇張表現
    • 説得力を演出するレトリックであり、技術的な論証の質を高めるものではない
  • 手法の粗さ:
    • ベンチマーク手法についても、本格的な分析ツールは使っていないと著者自身が認めている
    • 統計的な有意性・分散・複数回試行・異なるCPUアーキテクチャでの再現性などが示されていない

■ 7. 保守性軽視の二分法

  • 誤った二分法:
    • 記事末尾では、保守性のためにハードウェアの進化十年分を犠牲にするなんてあり得ないと述べるが、これは典型的な誤った二分法
  • 開発コストの実態:
    • 実際のソフトウェア開発コストの大部分は、初期開発や実行時性能ではなく保守・変更・デバッグにかかる人的コスト
    • エンタープライズソフトウェアの大多数(業務システム、CRUD API、UIロジックなど)では、CPU数サイクルの差は事業上ほぼ無関係
      • 可読性や変更容易性の方がはるかに高いROIを持つ
  • 適用領域の過度な一般化:
    • Muratoriのフレーム(ゲームエンジン/HPC的な視点)を、あたかも全てのソフトウェア開発に一般化できるかのように語っている
    • 対象領域の限定を怠った過度な一般化

■ 8. 帰属の粗さ

  • 単一集団としての扱い:
    • 記事は終始「クリーンコード提唱者」を単一の均質な集団として扱い、具体的に誰の・どの主張への反論なのかを明示しない
    • 唯一の脚注でも用語法の違いを認めつつ流している
  • 反論対象の曖昧化:
    • 実際には多様な立場を持つクリーンコード推進派をひとまとめに攻撃する構造になっている
    • 反論の対象が曖昧

■ 9. 総括:擁護点と難点

  • 擁護できる点:
    • 仮想関数呼び出しが間接分岐・キャッシュミスを誘発し、ホットループで測定可能な性能劣化を招くこと自体は、コンパイラ理論・CPUアーキテクチャの観点から妥当
    • ゲーム開発やHPC、組み込みなど性能クリティカルな領域では、データ指向設計がクリーンコードの一部の慣習より優れているという主張は業界内で広く支持されている
  • 擁護できない点:
    • 単一の教育的な簡素なサンプルをもってクリーンコード全般を性能的に断罪するのは一般化が過ぎる
    • 複数の交絡変数(間接参照・メモリレイアウト・アルゴリズム選択)を分離せず、一つの数字(15倍)に集約している
    • 保守性・開発速度・バグ率といった性能以外の重要な軸を、ハードウェア進化年数という一つの指標に矮小化して切り捨てている
    • レトリック(iPhone比喩など)が科学的厳密性を装った説得の道具として多用されている
  • 結論:
    • この記事は特定のホットパスにおけるポリモーフィズムのコストを可視化するという限定的な目的では価値がある
    • クリーンコードは性能面で受け入れがたいという一般的結論を導くための論証としては、サンプル選択・交絡変数・適用範囲の面で重大な弱点を持つ