プログラミング
「クリーン」コード、ひどいパフォーマンス (2023)
"Clean" Code, Horrible Performance (2023) (computerenhance.com)
要約
この記事は、多くのプログラミングにおける「クリーンコード」のベストプラクティス、特にポリモーフィズムの使用が、スイッチ文などの代替手段と比較して著しくパフォーマンスを低下させる可能性があると論じています。著者は、コードの構造をオブジェクト指向にし、関数を小さく保つといった一般的な推奨事項が、実際の実行速度に悪影響を与える場合があることを、具体的なコード例とパフォーマンス測定結果を挙げて示しています。
全文翻訳
プログラミングコース
「クリーン」コード、ひどいパフォーマンス
今日教えられている多くのプログラミングの「ベストプラクティス」は、パフォーマンスの災害となる可能性があります。
Casey Muratori
2023年2月28日
639
1445
共有
これは、パフォーマンスを意識したプログラミングシリーズからの無料のボーナスビデオです。このビデオは、「クリーンコード」のガイドラインに従うことの現実世界でのパフォーマンスコストを示しています。コースの詳細については、[About]ページまたは[Table of Contents]をご覧ください。
購読する
ビデオの軽微に編集されたトランスクリプトを以下に示します。
初心者プログラマーに特に繰り返し伝えられるプログラミングのアドバイスのいくつかは、「クリーン」なコードを書くべきだということです。その名称には、コードを「クリーン」にするために何をすべきかを指示する長いルールのリストが付随しています。
これらのルールの大部分は、実際に書いたコードの実行時間に影響を与えません。これらの種類のルールは客観的に評価できず、その時点ではかなり恣意的であるため、必ずしも評価する必要はありません。しかし、「クリーン」コードのいくつかのルール、特に最も強調されるものの中には、実行時のコードの動作に影響するため、客観的に測定できるものがあります。
「クリーン」コードの概要を見て、実際にコードの構造に影響を与えるルールを抜き出すと、次のようになります。
if/else や switch よりもポリモーフィズムを優先する
コードは、それが処理しているオブジェクトの内部を知るべきではない
関数は小さくすべきである
関数は1つのことをすべきである
DRY - Don't Repeat Yourself (繰り返しを避ける)
これらのルールは、特定のコード片が「クリーン」であるためにどのように作成されるべきかについて、かなり具体的に述べています。私が尋ねたいのは、これらのルールに従ってコードを作成した場合、そのパフォーマンスはどうなるかということです。
私が「クリーン」コードの実装にとって最も有利なケースを構築するために、私は「クリーン」コードの文献に含まれる既存の例コードを使用しました。こうすることで、私は何も作り出しているのではなく、単に「クリーン」コードの提唱者が示すルールを、それらのルールを説明するために彼らが与える例コードで評価しているだけです。
「クリーン」コードの例を見ると、しばしばこのような例を目にします。
/* ======================================================================== LISTING 22 ======================================================================== */
class shape_base {
public:
shape_base() {}
virtual f32 Area() = 0;
};
class square : public shape_base {
public:
square(f32 SideInit) : Side(SideInit) {}
virtual f32 Area() {return Side*Side;}
private:
f32 Side;
};
class rectangle : public shape_base {
public:
rectangle(f32 WidthInit, f32 HeightInit) : Width(WidthInit), Height(HeightInit) {}
virtual f32 Area() {return Width*Height;}
private:
f32 Width, Height;
};
class triangle : public shape_base {
public:
triangle(f32 BaseInit, f32 HeightInit) : Base(BaseInit), Height(HeightInit) {}
virtual f32 Area() {return 0.5f*Base*Height;}
private:
f32 Base, Height;
};
class circle : public shape_base {
public:
circle(f32 RadiusInit) : Radius(RadiusInit) {}
virtual f32 Area() {return Pi32*Radius*Radius;}
private:
f32 Radius;
};
これは、円、三角形、長方形、正方形といったいくつかの特定の形状が派生した、形状の基底クラスです。次に、面積を計算する仮想関数があります。
ルールが要求するように、私たちはポリモーフィズムを優先しています。私たちの関数は1つのことしか行いません。それらは小さいです。そのような良いことです。その結果、私たちは「クリーン」なクラス階層を持ち、各派生クラスは自身の面積を計算する方法を知っており、その面積を計算するために必要なデータを格納しています。
この階層を使用して何かを行うことを想像すると、例えば、渡された一連の形状の合計面積を見つける場合、次のようなものになると予想されます。
/* ======================================================================== LISTING 23 ======================================================================== */
f32 TotalAreaVTBL(u32 ShapeCount, shape_base **Shapes) {
f32 Accum = 0.0f;
for(u32 ShapeIndex = 0; ShapeIndex < ShapeCount; ++ShapeIndex) {
Accum += Shapes[ShapeIndex]->Area();
}
return Accum;
}
ここではイテレータを使用していないことに気づくかもしれません。なぜなら、イテレータを使用する必要があることを示唆するルールは何もなかったからです。そのため、「クリーン」コードに有利な判断を与え、コンパイラを混乱させてパフォーマンスを低下させる可能性のある抽象化されたイテレータを追加しないことにしました。
また、このループがポインタの配列上で行われていることにも気づくかもしれません。これはクラス階層を使用する直接の結果です。これらの各形状がメモリ上でどれくらいの大きさになるかはわかりません。そのため、各形状のデータサイズを取得するための別の仮想関数呼び出しを追加し、それらを通過するために何らかの可変スキップ手順を使用しない限り、各形状が実際にどこから始まるかを見つけるためにポインタが必要です。
これは累積であるため、ループには依存関係があり、ループが遅くなる可能性があります。累積は任意に並べ替えることができるため、念のため手動でアンロールしたバージョンも書きました。
/* ======================================================================== LISTING 24 ======================================================================== */
f32 TotalAreaVTBL4(u32 ShapeCount, shape_base **Shapes) {
f32 Accum0 = 0.0f;
f32 Accum1 = 0.0f;
f32 Accum2 = 0.0f;
f32 Accum3 = 0.0f;
u32 Count = ShapeCount/4;
while(Count--) {
Accum0 += Shapes[0]->Area();
Accum1 += Shapes[1]->Area();
Accum2 += Shapes[2]->Area();
Accum3 += Shapes[3]->Area();
Shapes += 4;
}
f32 Result = (Accum0 + Accum1 + Accum2 + Accum3);
return Result;
}
これらの2つのルーチンを単純なテストハーネスで実行すると、その操作を実行するために必要な形状あたりの合計サイクル数を大まかに測定できます。
ハーネスはコードを2つの異なる方法で時間測定します。最初の方法は、コードを1回だけ実行し、任意の「コールド」状態での状況を示します。データはL3にあるはずですが、L2とL1はフラッシュされており、分岐予測子はループで「練習」していません。
2番目の方法は、コードを何度も繰り返し実行し、キャッシュと分岐予測子がループにとって最も有利な方法で動作している場合に何が起こるかを見ます。これらのいずれもハードコアな測定ではないことに注意してください。なぜなら、これから見る違いは非常に大きいため、深刻な分析ツールを取り出す必要はないからです。
結果からわかるのは、2つのルーチンの間に大きな違いはないということです。形状の「クリーン」コード面積計算を実行するには、約35サイクルかかります。運が良ければ、時には34サイクル近くになることもあります。
したがって、35サイクルはすべてのルールに従った場合に期待できる値です。代わりに最初のルールを1つだけ違反した場合はどうなるでしょうか?ここでポリモーフィズムを使用する代わりに、スイッチ文を使用するとどうなるでしょうか?
ここではまったく同じコードを記述しましたが、クラス階層(したがって、実行時にはvtable)を使用する代わりに、列挙型と、すべてを1つの構造体にフラット化する形状タイプを使用しました。
/* ======================================================================== LISTING 25 ======================================================================== */
enum shape_type : u32 {
Shape_Square,
Shape_Rectangle,
Shape_Triangle,
Shape_Circle,
Shape_Count,
};
struct shape_union {
shape_type Type;
f32 Width;
f32 Height;
};
f32 GetAreaSwitch(shape_union Shape) {
f32 Result = 0.0f;
switch(Shape.Type) {
case Shape_Square:
{Result = Shape.Width*Shape.Width;}
break;
case Shape_Rectangle:
{Result = Shape.Width*Shape.Height;}
break;
case Shape_Triangle:
{Result = 0.5f*Shape.Width*Shape.Height;}
break;
case Shape_Circle:
{Result = Pi32*Shape.Width*Shape.Width;}
break;
case Shape_Count:
{}
break;
}
return Result;
}
これは、「クリーン」コードが登場する前に、これを記述していた「昔ながらの」方法です。
すべての形状バリアントの特定のデータ型がなくなったため、タイプが質問の値(たとえば「高さ」)の1つを持っていない場合、それは単に使用しないことに注意してください。
今、仮想関数呼び出しから面積を取得する代わりに、この構造体のユーザーは関数からそれを取得します。