Pythonを高速化する「Codon」コンパイラを使ってみた

2023.04.07

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

データアナリティクス事業本部のueharaです。

今回は、Pythonの実行がシングルスレッドで従来の10~100倍以上高速化すると言われている「Codon」というコンパイラを使ってみたいと思います。

Codonの概要

Pythonは世界的にも広く使われている言語であり、シンプルでわかりやすい文法や豊富なライブラリ、拡張性など、多くの利点があります。

しかし、その一方で、Pythonの実行速度に課題があるという指摘は皆さんも一度は耳にしたことがあるかと思います。

この課題を解決すべく、MITの研究者らが開発した「Codon」について紹介したいと思います。

Codonは、ランタイムのオーバーヘッドなしで Pythonのコードをネイティブなマシン語にコンパイルする高性能なPythonコンパイラです。

Codonを使うとシングルスレッドでも十分な高速化を行うことができますが、マルチスレッドもサポートしているため、更なる高速化も図れます。

インストール方法

OSがLinux (x86_64) か MacOS (x86_64 または arm64) であれば、公式からビルド済みのバイナリが配布されているので、1コマンドでインストールすることができます。

/bin/bash -c "$(curl -fsSL https://exaloop.io/install.sh)"

その他についてはソースからビルドする必要があり、以下の公式のドキュメントを参考にインストールすることができます。

Building from source - Codon

実行方法

以下のようにcodon runコマンドで、JIT (Just-In-Time)モードで実行することができます。

# JITモードで実行
codon run foo.py

また、以下のように実行ファイルをビルドして実行することもできます。

# 実行ファイルをビルドして実行
codon build -o foo foo.py
./foo

速度検証

Pythonでは比較的時間のかかる、104個の乱数に対しバブルソート(オーダー O(n2) )を行うという処理で実行速度を計測したいと思います。

※通常 O(n2) のオーダーのソート関数を使うことはありませんが、今回は検証のため敢えてこのような形をとっています。

test.py

import random
import time


# 10000個の乱数を生成
numbers = [random.randint(0, 10000) for i in range(10000)]

start_time = time.perf_counter()

# バブルソート
for i in range(len(numbers)):
    for j in range(len(numbers)-1):
        if numbers[j] > numbers[j+1]:
            numbers[j], numbers[j+1] = numbers[j+1], numbers[j]

execution_time = time.perf_counter() - start_time

# 結果を表示
print(numbers[:10])
print("time:", execution_time, "[s]")

上記の実行結果は以下の通りでした。

$ python test.py
[0, 5, 5, 7, 7, 8, 8, 8, 10, 12]
time: 9.712872083997354 [s]

体感でも結構待ったな、と思いましたが、10秒近くかかりました。

それに対し、上記プログラムをCodonを使って実行してみた結果は以下の通りです。

$ codon run test.py
[0, 1, 2, 3, 6, 7, 7, 7, 8, 8]
time: 1.04412 [s]

先程より約10倍高速化し、1秒程度で実行することができました。

上記プログラムでは、乱数を格納するのにListを使っていましたが、今度はCodonの静的配列である__array__を使ってみたいとおもます。

test2.py

import random
import time


# 静的配列を仕様
numbers = __array__[int](10000)

# 10000個の乱数を生成
for i in range(len(numbers)):
    numbers[i] = random.randint(0, 10000)

start_time = time.perf_counter()

# バブルソート
for i in range(len(numbers)):
    for j in range(len(numbers)-1):
        if numbers[j] > numbers[j+1]:
            numbers[j], numbers[j+1] = numbers[j+1], numbers[j]

execution_time = time.perf_counter() - start_time

# 結果を表示
print([numbers[i] for i in range(10)])
print("time:", execution_time, "[s]")

上記の実行結果は以下の通りです。

$ codon run test2.py
[1, 2, 3, 4, 4, 5, 5, 5, 5, 6]
time: 0.271737 [s]

先程よりも速く、0.27秒で実行が完了しました。

更に、Codonでは-releaseオプションを使うことで、最適化した状態でコンパイル・実行することができます。

$ codon run -release test2.py 
[1, 2, 2, 4, 4, 6, 6, 6, 7, 8]
time: 0.0445263 [s]

今回の検証結果をまとめると、以下の通りとなります。

実行方法 ソートにかかった時間(秒)
通常のPython (List利用) 9.71
Codon (List利用) 1.04
Codon (静的配列利用) 0.27
Codon (List利用) 最適化あり 0.05
Codon (静的配列利用) 最適化あり 0.04

結果を見ても明らかなように、大きく高速化できることが分かります。

制約事項や相違点等

速度を高速化できる一方で、Codonにはいくつかの制約やオリジナルのPythonと細かな違いがあります。

代表的なものとして、Codonは強い型付け言語として振る舞うため、オブジェクトに異なる型の値を代入することができません。

例えば、l = [i for i in range(5)]と定義されたリストはList[Int]型として扱われることから、このリストに対しstringである'aaa'を追加する処理を書くとコンパイルエラーとなります。

また、CodonはPythonと同様のクラスをサポートしていますが、各クラスの先頭でクラスメンバーとその型を宣言する必要があります。

class Foo:
    x: int
    y: int

    def __init__(self, x: int, y: int):  # constructor
        self.x, self.y = x, y

    def method(self):
        print(self.x, self.y)

f = Foo(1, 2)
f.method()  # "1 2"

また、CodonではC言語での「int *p = &x」のような変数へのポインタも扱うことができます。

例としては以下の通りです。

def count_up(argp: Ptr[int]):
    argp[0] += 1

x = 0
p = __ptr__(x) # xへのポインタ、C言語での"&x"
count_up(p)

print(x) # "1"

その他の内容については、公式のドキュメントをご確認下さい。

最後に

今回はPythonの実行を高速化することができるCodonを使ってみました。

参考になりましたら幸いです。

参考文献