Pythonはシェルスクリプトの延長くらいにしか使ってきてなかったのだけれど、最近もう少しちゃんとした用途で使う機会が増えてきた。そうなると、低レイヤ好きの人間としてはPythonがどのように実行されているのか気になってきた。
なので雑多につまみ食いしてみた。
Pythonの実装
言語の仕様と実装は別モノであるという話。
Pythonという言語はひとつだけれど、その言語の機能を実現するための方法はひとつではない。CPythonとかPyPyとか、こいつらはPythonという言語の、実装に対する呼称だ。C言語とかで言えばコンパイラにGCCとかClangが選べますみたいなところかな。
Pythonの実装の種類についてはPythonのWikipediaとかを見ればいいと思う。結構いろいろある。
で、いろいろな実装がある中でもCPythonがいわゆるリファレンス実装で、Pythonの原作者が実装していて、世の中のPython実行環境は大体これという、まさに本家的な位置付けになる。
名前からも想像できるように、CPythonはC言語で実装されていて、PyPyはPythonで実装されているらしい。そしてPyPyはCPythonよりも早い。
ん?C言語のほうがPythonより速いのに、CPythonよりPyPyが速いって、どういうこと?
そのあたりについても少し説明していきたい。
CPythonの仕組み
CPythonのWikipediaを読むと、
CPython はバイトコードインタプリタである。
とある。
ほう、バイトコードインタプリタ。なんですかそれは。
バイトコードとは中間表現のことだ。つまりCPython環境下では、Pythonはまずバイトコードに変換され、そのバイトコードを仮想マシン(VM)が実行する。
バイトコードを逐次解釈・実行するので、バイトコードインタプリタと呼ばれる。
なぜそんなことをするかと言えば、そのほうが速いということらしいが、インタプリタ上で動作する言語において、バイトコードを挟んだ方がトータル実行時間が決定的に速くなる理由については、私にはよく分からなかった。
ただ、少なくともインタプリタの実装はスッキリするだろうし、バイトコードをキャッシュとして残せば構文解析など大部分の処理を2回目以降は行わずに済むだろうし、確実に意味はありそうだ。
Pythonコードを実行すると.pycファイルや__pycache__ディレクトリができたりするが、どうもこれらにバイトコードが記録されているらしい。これらのバイトコードだけを別の環境に持っていって実行することも可能のようだ。
あれ?そういえば、こういう仕様の有名な言語あるよね。
そう、Javaだ。Javaの説明では大体最初のほうに、Javaコードはバイトコードに変換されて、それをJave VMが実行する、みたいな記述が出てくる。
PythonもJavaもソースコードをバイトコードに変換(コンパイル)してからVM(インタプリタ)が実行しているということになる。Javaはコンパイル言語で、Pythonはインタプリタ言語と認知されているが、その実態はコンパイルが明示的に行われるか、暗黙的に行われるか程度の違いでしかない。
PyPyはなぜ速いのか
なぜPythonで書かれたPyPyがCPythonより速いのか。それはJIT(Just In Time)コンパイルをしているためである。
JITコンパイルとはなにか。
ざっくり言えば実行するときに機械語にコンパイルしてから実行するので早くなるよ、というものだ。
例えば、ループ処理や何度も呼ばれる関数について考えてみる。
単純なインタプリタであれば、それらのコードが呼ばれる度に文法の解釈を行い、内容に基づいた処理をインタプリタが実行することになる。インタプリタの実態は当然機械語の集まりであるため、つまるところ、コード⇒機械語の変換を毎回行ってから機械語を実行しているようなものになる。だったら繰り返し呼ばれるコードはまとめて機械語に変換しておいて、同じコードが呼ばれたら直接機械語を実行するようにすれば、変換処理の時間が削減できそうだ。
他にも、コードを1行1行変換するようなインタプリタでは処理の流れに基づいた最適化はできないが、ある程度まとめてコードを読み込んで変換するなら、多少の最適化を行うこともできるだろう。
ただ、JITコンパイルによる高速化にもいろいろな手法があり、どういったところが高速化のキモなのかは正直理解できていない。
しかもPyPyでは、処理系のコードをJITコンパイルするという特殊な手法をとっているらしく、その中身まで理解するのはなかなか難しそうだ。
ちなみに、PyPyのダウンロードページを見ると、JITコンパイラはIntel系のCPUでしか動作しない旨が記載されている。
These binaries include a Just-in-Time compiler. They only work on x86 CPUs that have the SSE2 instruction set (most of them do, nowadays), or on x86-64 CPUs. They also contain stackless extensions, like greenlets.
ソースを確認していないので推測だが、JITコンパイルしているということは、言語処理系の中にCPUアーキテクチャに依存したアセンブリを生成する処理があるということになる。世の中に数多くあるCPUアーキテクチャに対応した処理を実装するのは、考えるだけで大変な作業だ。ユーザ数の多いIntel系のCPUに絞って実装されているのかもしれない。
蛇足が続くが、PyPy使った場合、Pythonコードを実行するとPyPyがそれを読み込んで実行する。ではPythonで書かれているPyPyは誰が実行しているのだろうか?
どうもPyPyのPythonコードはC言語に変換され、バイナリにコンパイルされたものが実行されているらしい。
NumbaのJITコンパイル対応
PyPyにはJITコンパイラが組み込まれていて速いことはわかったが、そういえばPythonにはNumbaというJITコンパイルしてくれるライブラリがあった。
Numbaのガイドを見ると、それなりの数のCPUアーキテクチャに対応しているようだ。
Architecture: x86, x86_64, ppc64le. Experimental on armv7l, armv8l (aarch64).
Numbaは頑張ってアーキテクチャごとの対応を実装しているのだろうか?
少し調べてみたところ、NumbaはLLVMを利用しているようだ。
LLVMを使っているのであればPythonコードをLLVM IR(LLVMの中間表現)に変換すれば、CPUアーキテクチャごとの対応はLLVM側でやってくれるため、Numba側で対応する必要はない。
まとめ
Pythonコードが実行される仕組みについて、気の赴くままに調べてみた。
もはやインタプリタ言語とコンパイラ言語の境目はほとんどないと感じた。高速化のためにインタプリタ言語でもコンパイルするし、コンパイル言語でも利便性のためにインタプリタっぽく動くものもある。
JITコンパイルについては何となく知っているつもりだったが、細かい話は全然分かっていなかった。
参考
Pythonの処理系はどのように実装され,どのように動いているのか?
Pythonは逐一解釈されるのか、それともコンパイルされるのか?