目次


魅力的なPython: PsycoでPythonの実行速度をCと同等にする

Pythonの特化コンパイラーPsycoを使う

Comments

Pythonは、通常、やりたいことを実現するのに充分な速度を備えています。Pythonのようなインタープリター / バイト・コンパイル型言語の実行速度について初心者プログラマーが抱く懸念の90 %は、素人考えにすぎません。最近のハードウェアを使えば、最適化されていないPythonプログラムでも、ほとんどが必要な程度には高速に実行されますので、アプリケーションを高速化するために余分なプログラミングの労力を費やす意味がありません。

というわけで、今回は、残りの10 %だけに注目したいと思います。ときどき、Pythonプログラムの実行速度が (他の言語のプログラムもそうですが) 手に負えないほど遅いときがあります。どの程度の速度が必要なのかは、目的によって大いに違ってきます。何ミリ秒かを削ぎ落とさなければならないということは、まずありませんが、実行に数分とか数時間とか数日、あるいは数週間かかるようなタスクでは、多くの場合、高速化の価値があります。また、実行速度の遅いものすべてがCPUのせいだというわけではないことにも注意する必要があります。たとえば、データベースの照会が完了までに何時間もかかる場合、得られたデータセットの処理に1分かかろうが2分かかろうが大した差ではありません。今回は、I/Oの問題も対象外とします。

Pythonプログラムの実行速度を上げるには、いろいろな方法があります。どんなプログラマーでも最初に浮かんでくる手法は、使用されているアルゴリズムやデータ構造を改良することです。効率の悪いアルゴリズムの各ステップに局所的な最適化 (micro-optimizing) を図るのは、徒労というものです。たとえば、現在の手法の複雑さのオーダーがO(n**2) だったとすると、いろいろなステップを10倍高速化しても、O(n) の手法を見つけ出すことに比べれば、ほとんど役に立ちません。この教訓は、アセンブリー・レベルでの書き換えのような究極の方法を検討する場合にもあてはまることです。Pythonで正しいアルゴリズムを選択すれば、不適当なアルゴリズムを使用しているアセンブリーを手作業で改良した場合よりも、多くの場合、高速に実行されるはずです。

最初に検討すべき第2の手法は、Pythonアプリケーションのプロファイルをとり、キーとなる部分をCの拡張モジュールとして書き換えることを念頭にして眺めることです。SWIG (参考文献参照) のような拡張ラッパーを使用すると、Cのコードがプログラム中のほとんどの時間を消費するようなC拡張を作成することになりかねません。このような方法でPythonを拡張することは、比較的簡明なやり方ですが、習得にある程度時間がかかります (Cの知識も必要になる)。非常に多くの場合、Pythonアプリケーションの実行時間の大半は、ほんの一握りの関数で消費されており、したがって、かなりの改善が可能であることがわかります。

3番目の手法は、2番目の手法の上に立脚するものです。Greg Ewingは、PythonとCを混合したPyrexという言語を開発しました。とくにPyrexを使う場合、Pythonに似た言語を使用して、特定の変数について型宣言を行いながら関数を記述します。Pyrex (ツール) は、.pyxファイルを処理して .c拡張子に変換します。Cコンパイラーでコンパイルすると、これらのPyrex (言語) モジュールは、通常のPythonアプリケーションにインポートして使用することができるようになります。Pyrexは、Python自体とほとんど同じ構文を使用しますので (ループや分岐や例外処理の文、代入の形式、ブロックの字下げなど)、Pyrexプログラマーは、拡張モジュールの記述にCを習得する必要がありません。また、Pyrexでは、Cで直接記述される拡張モジュールの場合に比べ、同じコードの中でCのレベルの変数とPythonレベルの変数 (オブジェクト) をスムーズに混在させることができます。

最後の手法が、今回のテーマです。拡張モジュールPsycoは、Pythonインタープリターの内部にプラグインすることができ、Pythonが解釈したバイトコードの一部を選択的に最適化されたマシン・コードに置き換える働きをします。上記の他の手法と異なり、Psycoは、まさしくPythonの実行時に動作します。すなわち、Pythonのソース・コードは、以前とまったく同様にpython コマンドによってコンパイルされ、バイトコードに変換されます (Psycoを起動するために、いくつかのimport 文と関数呼び出しが追加される点を除き)。ただし、Pythonインタープリターがアプリケーションを実行している間、Psycoは、通常のPythonのバイトコードの動作を、特化されたマシン・コードで置き換えることができないか、ときどきチェックします。この特化されたコンパイルは、Javaのジャスト・イン・タイム・コンパイラーがやっていることに非常に似ていますし (少なくとも、大まかに言って)、アーキテクチャーに対応したものともなっています。現在のところ、Psycoは、i386 CPUアーキテクチャー対応のものしか用意されていません。Psycoの素晴らしいところは、これまで作成してきたPythonコードをまったくそのまま使用しながら、そのコードを非常に高速に実行できるようにするという点です。

Psycoの動作原理

Psycoを完全に理解するためには、多分、Pythonインタープリターのeval_frame() 関数とi386アセンブリーの両方について充分理解しておく必要があります。残念ながら、私自身は、いずれの専門知識も持ち合わせていませんが、Psycoの概要なら、さほど間違いを冒すことなく説明できるのではないかと思います。

通常のPythonでは、eval_frame() 関数は、Pythonインタープリターの内部ループになっています。基本的に、eval_frame() 関数は、実行コンテキスト中の現在のバイトコードを調べ、そのバイトコードを実装するのにふさわしい関数に制御を切り換えます。具体的にこのサポート関数が何を行うかは、通常、メモリー中に保持されているPythonのさまざまなオブジェクトの状態によって違ってきます。話を単純化すると、Pythonのオブジェクトの2と3の和は、オブジェクト5と6の加算とは異なる結果になるが、どちらの演算も同様の方法でディスパッチされる、というわけです。

Psycoは、eval_frame() 関数を、複合的な評価ユニット (compound evaluation unit) で置き換えます。Psycoは、さまざまな方法で、Pythonの行っていることを改善することができます。まず第1に、Psycoは、演算をある程度最適化したマシン・コードにコンパイルします。マシン・コードが行うべきことは、Pythonのディスパッチ関数が行うことと同じですので、このこと自体は、わずかな改善しかもたらしません。また、Psycoのコンパイルで「特化」されていることは、Pythonのバイトコードの選択にとどまらず、実行コンテキスト中でわかっている変数の値についても特別な処理を行っています。たとえば、以下のようなコードの場合、ループ内でのx の値は知ることが出来ます。

x = 5
l = []
for i in range(1000):
    l.append(x*i)

このコードの最適化版では、(更新される)i 毎に「変数オブジェクト x の内容」を掛ける必要はありません。単に各i に5を掛けてしまえば経済的で、検索 (lookup) と参照解除 (dereference) を節約することができます。

小さな演算でi386対応のコードを生成することに加えて、Psycoは、このコンパイル済みのコードを、後で再利用できるようにキャッシュしておきます。ある演算が以前に実行され (「特化され」) たものと同じであることが認識できる場合には、Psycoは、そのセグメントをコンパイルし直すのではなく、キャッシュに入れておいたコードを使います。これによって、さらに少し時間が節約されます。

しかしながら、Psycoの真の節約は、Psycoが演算を3つのレベルに分類することによって得られています。Psycoは、変数に実行時 (run-time)、コンパイル時 (compile-time) および仮想時 (virtual-time) の3種類があるとみなします。Psycoは、必要に応じて、変数のレベルを昇格 (promote) させたり、降格 (demote) させたりします。実行時変数は、通常のPythonインタープリターが処理する、単なる生のバイトコードやオブジェクト構造です。コンパイル時変数は、演算が Psycoによってマシン・コードにコンパイルされるときに、マシン・レジスターや直接アクセスされるメモリー・アドレスで表現されます。

最も面白いレベルが仮想時変数です。Pythonの変数は、たとえ、オブジェクトが1個の整数しか表していない場合でも、内部的には、たくさんのメンバーからなる完全な構造体となります。Psycoの仮想時変数は、必要が生じれば構築することができるが、それまでは詳細の省略されているPythonオブジェクトのことです。たとえば、以下のような代入があったとします。

            x = 15 * (14 + (13 - (12 / 11)))

標準のPythonは、この値を算出するために、数多くのオブジェクトを構築 (build) しては、破棄 (destroy) します。まず、(12/11) の値を保持するために、完全な整数オブジェクトが1個構築されます。次に、この一時オブジェクトの構造体から値が取り出され、それを使用して新しい一時オブジェクト(13-PyInt) が算出されます。Psycoは、これらのオブジェクト操作を飛ばし、「必要なら」値からオブジェクトを作成できることがわかっていますので、単に値の計算だけを行います。

Psycoの使用法

Psycoの説明がなかなか難しいのに対して、Psycoの使い方 は、はるかに簡単です。基本的には、どの関数 / メソッドを「特化」すればよいのかをPsycoモジュールに伝えてやればよいわけです。Pythonの関数やクラス自体には、まったく変更を加える必要がありません。

Psycoの行うべきことを指定する方法には、いく通りかがあります。「ショットガン」アプローチは、Psycoのジャスト・イン・タイム演算を、いたるところで有効にするという方法です。それには、モジュールの先頭に以下の行を入れておきます。

import psyco ; psyco.jit() 
from psyco.classes import *

最初の行は、Psycoに対して、すべてのグローバル関数にPsycoの魔法を利かすように指示しています。2行目 (Python 2.2以上で有効) は、Psycoに対して、クラスのメソッドにも同じことを行うように指示します。Psycoの動作をもう少し正確に指定したいときは、以下のようにコマンドを使います。

psyco.bind(somefunc)          # or method, class
newname = psyco.proxy(func)

この二つ目の形式では、func は標準のPython関数のまま置いておかれますが、newname による呼び出しは最適化されます。テストやデバッグ以外のほとんどすべての場合、psyco.bind() の形式を使用することになるでしょう。

Psycoの性能

Psycoが魔法のツールだとはいっても、それを使うには少し知恵やテストが必要です。理解しておくべき主なことは、Psycoがループの回数の多いブロックの処理に効果を発揮し、整数や浮動小数点数の演算を最適化する方法を知っているということです。ループのない関数や、整数や浮動小数点数以外の型のオブジェクトの演算については、Psycoは、たいてい解析と内部コンパイルのぶんだけオーバーヘッドを増やすだけです。また、大量の関数やクラスを使用するアプリケーションの場合、アプリケーション全体にPsycoを適用すると、マシン・コードのコンパイルやそれらのコードをキャッシュするためのメモリーの面で大きな負担をかけることになります。Psycoが行う最適化を最大限活用できるような関数を選択的にバインドするほうが、はるかに良好な結果が得られます。

私は、まず、非常に単純な方法でテストを行いました。最近実行したアプリケーションで、高速化することなど気にもかけなかったものをテストしてみようと思いました。最初に頭に浮かんだのは、私の近刊書 (Text Processing in Python) の原稿をLaTeXのフォーマットに変換するためのテキスト操作プログラムでした。これは、文字列メソッドと正規表現と、ほとんどが正規表現と文字列のマッチで駆動されるプログラム・ロジックをそれぞれ少しずつ使用するアプリケーションです。実際は、Psycoにとって大変な相手なのですが、これを使うことにします。というわけで、まずは、このアプリケーションをテストしました。

1回目は、スクリプトの先頭にpsyco.jit() を追加するだけにしました。何の労力でもありません。残念ながら、(予想どおり) 期待はずれの結果でした。このスクリプトは、最初は約8.5秒かかっていましたが、Psycoで「高速化」を図ると、約12秒かかるようになりました。いい結果とは言えません。多分、ジャスト・イン・タイム・コンパイルで起動時のオーバーヘッドがある程度必要となり、それが実行時間を長引かせたのではないかと思います。そこで、次に、ずいぶん大きな入力ファイル (同じものを何個もコピーして作成したもの) を処理させることにしました。この場合、実行時間が約120秒から110秒になり、わずかながら時間短縮になりました。高速化は、何回試みても、だいたい同じ結果でしたが、いずれにせよ、まったく無意味なものでした。

テキスト処理アプリケーションを使っての2回目の試み。main() 関数でかなりの回数のループを行って いる (それに対して、整数演算はごくわずかしか行っていない) ので、包括的なpsyco.jit() 呼び出しを行う代わりに、psyco.bind(main) の行だけを加えることにしました。その結果は、わずかながら改善しました。この方法の場合、通常の実行時間が10ぶんの何秒か〔200~300ミリ秒程度〕短縮され、大きな入力ファイルについては数秒の短縮となりました。といっても、何も目を見張るほどのものはありません (悪さもしていませんが)。

Psycoをテストするのにもっと適当なものがないかと思い、私が以前に執筆したニューラル・ネットワークのコード (参考文献参照) を見つけ出してきました。このcode_recognizerアプリケーションは、いろいろなプログラミング言語にASCIIコードの分布のようなものがあれば、それを認識するように訓練できるというものでした。このようなアプリケーションがあると、ファイルの種類 (たとえば、迷子になったネットワーク・パケットの) を推測するのに役に立つかもしれませんが、実際には、コードは、まったく汎用的で、どんなファイルを対象に訓練するのかには関係ありませんので、顔や音や潮の干満のパターンを簡単に認識するように学習するかもしれません。いずれにせよ、code_recognizerは、bpnn というPythonのライブラリーを利用しており、このライブラリーもPsyco 0.4ディストリビューションにテスト・ケースとして (変更を加えた形で) 含められています。本稿で、code_recognizerに関して知っておくべき重要なことは、それが浮動小数点演算のループをたくさん含んでいて、実行に長い時間がかかるという点です。Psycoを働かせる格好の対象が見つかったということです。

少し扱ってみて、Psycoの使い方の細かい点が、いくつか、わかってきました。このアプリケーションの場合、クラスと関数の数が少ないため、ジャスト・イン・タイムを使おうがバインドの的を絞ろうが、大きな差は出てきません。といっても、やはり、最も最適化が可能なクラスを選択的にバインドすることで、数パーセントの差ながら、最善の結果が得られます。しかし、もっと重要なことは、Psycoのバインドの範囲を理解しておくことです。

code_recognizer.py スクリプトには、以下のような行が含まれています。

bpnnからのNNのインポート
class NN2(NN):
    # customized output methods, math core inherited

すなわち、Psycoの視点から見て興味深いことが、bpnn.NN にあるのです。code_recognizer.py スクリプトにpsyco.jit() を追加しようがpsyco.bind(NN2) を追加しようが、ほとんど効果はありません。Psycoにやってほしい最適化をやらせるには、code_recognizer.pypsyco.bind(NN) を追加するか、bpnn.pypsyco.jit() を追加するかです。多分皆さんが想定していたこととは反対に、ジャスト・イン・タイムは、インスタンスが作成されるときでもなく、メソッドが実行されるときでもなく、クラスが定義されるときに起こるのです。また、子孫のクラスをバインドしても、別のところから継承されているメソッドは特化されません。

Psycoのバインドを正しく利用するときの細かな注意点を見いだすことができると、かなり感動的な速度向上が得られるようになります。参考文献の記事で行ったときと同じテスト・ケースおよび訓練形式 (訓練パターンが500、訓練の繰り返し回数が1000) にした場合、ニューラル・ネットの訓練時間は、約2000秒から約600秒に短縮されました。3倍以上の速度向上です。繰り返し回数を10回まで減らすと、速度の向上もそれに比例したものになり (ニューラル・ネットの認識としては、無意味ですが)、また途中のそれ以外の繰り返し回数の場合も同様でした。

新しいコードを2行追加することで、30分以上かかっていた実行時間を約10分に短縮できたというのは、かなり顕著な効果だと思います。この速度向上でも、多分、Cで記述した同様のアプリケーションの速度よりも、まだ遅いでしょうし、Psycoのいくつかの単体でのテスト・ケースが示している100倍という速度向上よりも劣っていますが、このアプリケーションは、非常に「現実的な」もので、この程度の改善でも、数多くのコンテキストでは充分意味のあるものです。

Psycoのゆくえ

Psycoは、現在のところ、内部的に統計をとったりプロファイルを作成するといったことは何も行っておらず、生成されたマシン・コードに最小限の最適化を施しているだけです。もしかすると、将来のバージョンでは、実際に最も効果のあるPythonの演算に的を絞り、最適化できない部分についてはキャッシュに入れておいたマシン・コードを捨てる、といった手法を身に付けるようになるかもしれません。あるいは、将来のPsycoは、繰り返し実行される演算には、もっと広範な (しかしコストも高くつく) 最適化を実行する途を選ぶかもしれません。そのような実行時の解析は、SunのHotSpotテクノロジーがJava向けに行っていることに似てくるのかもしれません。Pythonと異なり、Javaに型宣言があるということは、実際には、多くの人が考えるほど重要なことではありません (Self、Smalltalk、Lisp、Schemeの最適化に関する以前の研究も、このことを証明しています)。

実現することはないだろうとは思いますが、Psycoのような種類のテクノロジーがPython自体の将来のバージョンに組み込まれることがあれば、面白いのではないかと思います。インポートやバインドを行うための数行を追加することは大したことではありませんが、Python自体を本質的にもっと速く実行できるようにするのがよりシームレスなやり方でしょう。どうなっていくか、見守っていくことにしましょう。


ダウンロード可能なリソース


関連トピック

  • SourceForgeのPsycoのホームページプロジェクトのページには、さらにいろいろな情報が示されています。
  • Simplified Wrapper and Interface Generator (SWIG) は、Pythonなどの「スクリプト」言語向けにC/C++ のモジュールを記述するときに広く使われているツールで、おそらくは最もよく使われているツールです。
  • Pythonの拡張モジュールを記述するための言語Pyrex がGreg Ewingによって開発されています。Pyrexの基本的な考え方は、Python自身に非常によく似ていて、PythonとCのデータ型を組み合わせることが可能で、最終的に、それをPythonのC拡張に変換、コンパイルできるような言語を定義することです。
  • John Max SkallerのVyper言語は、Pythonの機能拡張版として開発されたもので、OCamlで実装されています。このプロジェクトで期待されていたことの1つは、OCamlが生成するものと同じマシン・コード (通常、Cの速度と同等のもの) へのコンパイルでした。残念ながら、Vyperプロジェクトは中止され、コンパイル版が完成をみることはありませんでした。まだこのプロジェクトが活動していた頃のDavidのSkallerへのインタビュー (Python実装に駆り立てたもの -- VyperおよびStackless Pythonの作者とのインタビュー) (developerWorks、2000年10月) も参照してください。
  • DavidとAndrew Blaisの共同執筆によるニューラル・ネットワーク入門 (developerWorks、2001年7月)。この記事では、Neil SchemenauerのPythonモジュールbpnn を利用したコードが紹介されています。本稿では、このニューラル・ネットワークのコードをPsycoの機能を調べるために利用しています。
  • bpnn モジュールは、現在のPsycoディストリビューションにも、テスト・ケースとして、変更を加えた形で含められています。元のモジュール
  • developerWorks のLinuxゾーンには、他にもLinux関係の記事が多数掲載されています。

コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Linux
ArticleID=229881
ArticleTitle=魅力的なPython: PsycoでPythonの実行速度をCと同等にする
publish-date=10012002