サンプルプログラム
鶴は足が2本、亀は足が4本です。
ではランダムな100万匹の動物の足の本数を数えるプログラムを書いてみましょう。
C++の場合
C++ではクラスの概念がありますから、Animalクラスを基底として、足の本数を得る処理を隠蔽するとスマートですよね。
class Animal {
public:
std::string kind;
virtual ~Animal() = default;
virtual int leg_num() const = 0;
};
class Tsuru : public Animal {
public:
Tsuru() { kind = "tsuru"; }
int leg_num() const override { return 2; }
};
class Kame : public Animal {
public:
Kame() { kind = "kame"; }
int leg_num() const override { return 4; }
};
static long long sum_legs(const std::vector<std::unique_ptr<Animal>> &animals) {
long long total = 0;
for (const auto &a : animals) {
total += a->leg_num();
}
return total;
}
これで100万匹の動物の足を数えると、私の環境ではおよそ500msほどで処理できました。
Pythonの場合
Pythonでも同様にクラスを使うことができますね。
class Animal:
def leg_num(self) -> int:
raise NotImplementedError
def kind(self) -> str:
raise NotImplementedError
class Tsuru(Animal):
def leg_num(self) -> int:
return 2
def kind(self) -> str:
return "tsuru"
class Kame(Animal):
def leg_num(self) -> int:
return 4
def kind(self) -> str:
return "kame"
def sum_legs(animals: list[Animal]) -> int:
total = 0
for a in animals:
total += a.leg_num()
return total
見ての通り、C++とほぼ同じ構造ですね。
このコードを使って100万匹の動物の足を数えると、私の環境ではおよそ3000msほどで処理できました。
なぜ、C++とPythonでこんなに差が出るのでしょうか?
性能の違いの理由
一番大きな違いは、C++がコンパイル言語であり、Pythonがインタプリタ言語であることです。
C++はコンパイル時にコードが機械語に変換されるため、実行時には高速に動作します。
一方、Pythonはコードが逐次的に解釈されるため、実行時にオーバーヘッドが発生します。
特にtotal += a.leg_num()のようなメソッド呼び出しは、Pythonでは非常に遅いです。
このため、同じアルゴリズムを実装しても、C++の方が圧倒的に高速に動作します。
でも、なぜpythonが人気なのか?
確認した通り、pythonは言語自体としては非常に遅いです。
では、なぜpythonはこれほどまでに人気があるのでしょうか?
それは、pythonが持つ豊富なライブラリとエコシステムにあります。
特にデータサイエンス、機械学習、ウェブ開発などの分野では、pythonのライブラリが非常に充実しており、開発効率を大幅に向上させることができます。
思いついたアイデアをすぐ形にできる、ということです。
また、pythonのライブラリは、その中身は実はpythonではないことが多いです。
つまり、ライブラリが提供する関数を呼び出すと、その中でCやC++で書かれた高速なコードが実行される、という形です。
そのため、python自体の遅さを感じることなく、高速な処理を実現できます。
なんなら、C++を使って自分でアルゴリズムを考えて実装するよりも、
pythonのライブラリを探して使った方が、圧倒的に高速に動作することも多いです。
(特にマルチスレッドやGPUを活用する場合などは自作で性能を出すのが非常に難しいです)
C++でもまだ遅い
では、C++で書いたコードは十分に高速なのでしょうか?
実は、C++でもまだまだ高速化の余地があります。
例えば、上記のコードでは(わざと)仮想関数を使っています。
virtual int leg_num() const = 0;という部分ですね。
これは、Animalクラスにおいては、leg_num関数の実装が派生クラスに依存することを意味しています。
つまり、leg_num関数が具体的に何を行うか、実行時までわからない、ということです。
このため、C++コンパイラは最適化を行うことができず、実行時に関数ポインタを使って呼び出し先を決定する必要があります。
実際には、各動物の足の数を取得したいだけの処理なのに、わざわざ関数化して、最適化の妨げにしてしまっているわけです。
下手に格好の良いコードを書いたせいで、性能がでなくなってしまったのです。
(実はC++でも高速に書けますが、)ここはC言語でシンプルに書いてみましょう。
C言語の場合
struct Animal {
char kind[8];
int legs;
};
static long long sum_legs(const struct Animal *animals, size_t n) {
long long total = 0;
for (size_t i = 0; i < n; ++i) {
total += animals[i].legs;
}
return total;
}
これだけで良かったんです。
このコードを使って100万匹の動物の足を数えると、私の環境ではおよそ40msほどで処理できました。
C++と比較してもさらに10倍以上高速化できたことになります。
コードをシンプルに書く、というのはコード量のことではありません。
今回の例では、total += a->leg_num();はシンプルでなく、total += animals[i].legs;はシンプルでした。
なぜシンプルでないのか、というのを正確に理解するには、プログラミング言語に対して表面的な仕様の理解だけではなく、「そのコードがどのように実行されるか」を理解する必要があります。
ここまで書ければ、まず足の数を取得するのに関数呼び出しは不要になりますし、コンパイラも最適化を積極的に行うことができます。
コンパイラはforループを1つずつ回すことをやめ、AVX2命令を使って、複数の足の数を同時に加算するようになります。
C++やpythonの例では、forループ1回ごとに関数呼び出しが発生していたのが、
そもそも1回の処理で複数の動物の足を一気に加算できるようになるわけですから、圧倒的に高速になります。
その代償として、クラスの概念がなくなり、誰でもAnimal構造体の中身を直接操作できてしまう、というデメリットがあります。
なので実際には、C言語の例とC++の例の良いところを取ったような実装が好ましいわけです。
上記の例では、leg_numを変に仮想関数するべきではなかったですし、そもそもTsuruやKameをサブクラスとして定義する必要もなかったです。
まとめ
- 巨大な回数のループを回す場合、性能を頭の片隅に置こう
- そもそもpythonは遅い言語です
- しかし、豊富なライブラリがあり、ライブラリの中身はCやC++で書かれているため、高速な処理ができる
- C++などのコンパイル言語でも、過剰な抽象化を行うと、コンパイラの最適化が妨げられる
- 基本的に「なんでも入れられるクラス」や「なんでも対応できる関数」は性能を犠牲にする
- コンパイラが最適化しやすい、シンプルなコードを書くことが重要です