動機
私は学生時代、数値シミュレーションを用いた研究をしていました。数値シミュレーション自体はツールを使いましたが、そのツールによって出力されたファイルの解析ツールを自作していました。初めはPythonを使って解析ツールを作成していましたが、途中でJuliaに乗り換えました。理由は「Pythonのように書きやすくてCのように速い」「科学技術計算に向いている」という話を聞いたからです。なぜ速いのか、なぜ科学技術計算に向いているのか、調べたことは何度かあります。しかし、その度に???となって挫折しました。そうして「なんか早いPythonみたいな」という説明で卒業まで乗り切りました。
私が挫折したのはJuliaの理解だけではありません。基本情報技術者試験の勉強も何度か挫折しています。とりあえず参考書を一周して、その後問題集を解きまくる戦法を取ろうとするのですが、うまくいきません。原因は2つ考えられます。1つ目は寄り道しすぎてしまうこと、2つ目は寄り道している間に忙しくなって期間が空き、また最初から読み初めてしまうことです。
最近、基本情報技術者試験の勉強を再開しました。今回は本気です。試験の申込も済ませ、背水の陣を取りました。その日は基本情報の勉強を2時間以上しました。進んだページ数は2ページです。???
思い切り寄り道しました。参考書にコンパイル言語とインタプリタ言語について書かれていたので、Geminiに「コンパイル言語はわかるが、インタプリタ言語は何してるかよくわからん」という疑問から始まり、疑問が連鎖していった結果、「Juliaってなんで速いの?」という問いにたどり着きました。Geminiは私をよいしょしつつ、一生懸命説明してくれますが、よくわかりません。しかし、私はあきらめずに対話を重ね、完全に理解しました。そんな気がしました。私の記憶はあてにならないので、文章に残します。
PythonとC
予備知識としてPythonとCの説明をします。
それぞれの言語で配列を昇順にソートする(バブルソート)プログラムを書いてみます。
上のコードブロックがPython、下のコードブロックがCです。
# バブルソート関数
def bubble_sort(arr):
n = len(arr)
for i in range(n, 1, -1):
for j in range(i - 1):
if arr[j] > arr[j + 1]:
buf = arr[j]
arr[j] = arr[j + 1]
arr[j + 1] = buf
# メイン関数
def main():
arr = [4, 1, 3, 2]
print("ソート前:", arr)
# バブルソート
bubble_sort(arr)
print("ソート後:", arr)
# プログラムの実行
main()
#include <stdio.h>
// バブルソート関数
void bubble_sort(int arr[], int n)
{
int buf = 0;
for (int i = n; i >= 2; i--){
for (int j = 0; j < i - 1; j++){
if (arr[j] > arr[j + 1]){
buf = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = buf;
}
}
}
}
// メイン関数
int main(void)
{
int arr[] = {4, 1, 3, 2};
int n = sizeof(arr) / sizeof(arr[0]);
printf("ソート前: ");
for (int i = 0; i < n; i++){
printf("%d ", arr[i]);
}
printf("\n");
// バブルソート
bubble_sort(arr, n);
printf("ソート後: ");
for (int i = 0; i < n; i++){
printf("%d ", arr[i]);
}
printf("\n");
return 0;
}
どちらが書きやすそうでしょうか。多くの人がPythonの方が書きやすそうだと思うでしょう。それはなぜでしょうか。その理由の1つに「変数の型の有無」が挙げられると思います。変数を宣言する際、Pythonは変数の型を明記する必要がありません。一方、Cは変数が整数ならint、実数ならdouble、、、のように変数の型を明記する必要があります。
Pythonでは変数の型は動的です。変数xに10が格納されていれば、xは整数型であり、xに3.14が格納されていればxは実数型です。整数型の変数xに文字列を代入すればxは文字列型の変数になります。一方、Cでは変数の型は静的です。変数を宣言した瞬間、その変数の型が決まります。整数型の変数に実数を代入すると少数部が切り捨てられて整数になり、文字列を代入しようとするとエラーになります。このようにPythonの方が柔軟なコーディングができます。
Pythonの変数の型は動的なので、演算の度に変数の型を推論する必要があります。一方、C言語の変数の型は静的なので、型推論の必要がなく、Pythonよりも高速にプログラムを実行できます。
また、Pythonはインタプリタ言語であり、C言語はコンパイラ言語です。インタプリタはまずソースコードを一括で中間コードに変換し、中間コードを1行ずつ機械語に翻訳しながらプログラムを実行します。コンパイラはソースコードを一括で機械語に翻訳し、実行ファイルに出力します(コンパイル)。実行ファイルからプログラムを実行します。インタプリタ言語は翻訳と実行を同時に行うため手軽であり、1行ずつ実行するためデバッグがしやすく、試行錯誤に向いています。コンパイラ言語はインタプリタ言語よりとっつきにくく、デバッグもしにくい傾向がありますが、高速で動作します。
研究者はPythonで試行錯誤しながらコーディングし、プログラムが完成したら高速化させるためにCでコーディングしなおしていました(二言語問題)。これは二度手間であり、バグの原因にもなります。そこでPythonのように書きやすく、Cのように速い言語、Juliaが開発されたのです。
Juliaはなぜ「Pythonのように書きやすい」のか
JuliaがなぜPythonのように書きやすいのか説明するには、実際にコードを見てもらうのが速いと思います。以下に配列を昇順にソートする(バブルソート)プログラムを示します。
# バブルソート関数
function bubble_sort!(arr)
n = length(arr)
for i in n:-1:2
for j in 1:i-1
if arr[j] > arr[j + 1]
buf = arr[j]
arr[j] = arr[j + 1]
arr[j + 1] = buf
end
end
end
end
# メイン関数
function main()
arr = [4, 1, 3, 2]
println("ソート前: ", arr)
# バブルソート
bubble_sort!(arr)
println("ソート後: ", arr)
end
# プログラムの実行
main()
PythonとC、どちらに似ているかと言われたら、きっとPythonと答えるでしょう。Juliaの変数の型もPythonと同じく動的であり、宣言時に明記する必要はありません。
Juliaはなぜ「Cのように速い」のか
ここからが本題です。なぜJuliaは速いのでしょうか。それはJuliaには以下の2つの機能が備わっているからです。
- 多重ディスパッチ
- JITコンパイラ
多重ディスパッチとは、関数が定義された際、内部的には引数の型の組合せの数だけ関数が定義され、実体として引数が与えられると引数の型の組合せに応じて最適な関数を選択する仕組みのことです。後に説明するJITコンパイラはJulia以外の言語にも実装されているので、多重ディスパッチこそJuliaの特徴を表していると言えます。
例えば、2つの引数a、bを持つ関数が定義されると、aとbがともに整数型の場合に最適化された関数、aが整数型でbが実数型の場合に最適化された関数、aとbがともに実数型の場合に最適化された関数、、、のように多重に定義されます。引数として整数aと整数bが与えられると、多重に定義された関数の中からaとbがともに整数型の場合に最適化された関数が選択されます。
JITコンパイラとは、関数が初めて呼び出されたとき、その関数をコンパイルしてコードに埋め込むプログラムです。ソースファイルを一括でコンパイルせず、動的にコンパイルするため、Juliaはコンパイル言語でもインタプリタ言語でもない、またはどちらでもある言語と言えます。コンパイルはLLVMという超高速のコンパイラによって行われます。
Juliaが初めて関数を呼び出すときの処理を以下に示します。
- 型推論で引数の型を特定する
- 引数の型に対応する最適化された関数を選択する
- 選択された関数をコンパイルする
- コンパイル結果をコードに埋め込む
これだけではコンパイルの時間を考慮するとむしろPythonよりも遅くなる可能性があります。Juliaが真価を発揮するのは関数が何度も呼び出される場合です。
Juliaが二回目以降呼び出される場合の処理を以下に示します。
- 引数の型が埋め込まれた関数の引数と一致するか調べる
- 一致する場合は埋め込まれた関数を実行する
- 一致しない場合は初めて関数を呼び出すときの処理を実施し、すでに埋め込まれている関数と条件分岐の形にして埋め込む
例えば、引数xを持つ関数について、1回目の呼び出しでxの型が整数型で、2回目の呼び出しでxの型が実数型だった場合、3回目の呼び出しでは「xの型は整数型か?実数型か?それ以外か?」の択が生じます。整数型や実数型ならすでに埋め込まれている関数が実施され、それ以外なら4回目以降、さらに条件分岐が増えることになります。
Juliaの弱点
Pythonのように書きやすく、Cのように速いJulia。しかし、Juliaにも弱点があります。
まず、最初に関数を呼び出す際、時間がかかることです。これが最大の弱点です。これはTTFT(Time to first plot)と呼ばれています。関数を何度も呼び出さないようなプログラムにJuliaは不向きなのです。
次に、日本語の記事が少ないことが挙げられます。Juliaの記事の多くは英語の記事です。一方、PythonやCなどは日本語の記事がたくさんあり、情報を仕入れやすいです。
また、これはJuliaの弱点というわけではないですが、Pythonが分野によっては高速に実行できることが挙げられます。例えば機械学習はたくさんのライブラリが存在します。ライブラリ自体はPythonではなくCなどの高速な言語で書かれているので、たいして速度が落ちません。
Juliaは同じ関数を何度も呼び出し、まだPythonのライブラリが開発されていない分野、例えば科学技術分野などに向いていることになります。
グローバル変数を使うと遅くなるのはなぜか
Juliaには注意事項があります。それは「グローバル変数を使うとパフォーマンスが激減する」ということです。その理由はJuliaの特徴である多重ディスパッチを腐らせてしまうからです。多重ディスパッチは引数の組合せによって最適な関数を定義します。しかし、グローバル変数が使われると、プログラムの途中でグローバル変数の型が変更される可能性があるので、プログラムの中に頻繁に型推論が入ってしまいます。よってプルグラムを最適化できないのです。
あとがき
以上、Juliaがなぜ速いのか、なぜ科学技術計算にむいているのか、なぜグローバル変数を使うと遅くなるのか、といったトピックについて説明してきました。たぶん間違って説明しているところがあると思うので、指摘していただけるとありがたいです。ここまで記事を読んでいただきありがとうございました。