Pythonコーディングでよく議論されるのは、シミュレーション実行のパフォーマンスを最適化する方法です。定量的なコードを考える際には、NumPy、SciPy、pandasはすでにこの点で非常に有用ですが、システムを構築する際には、これらのツールを効果的に使うことはできません。コードを高速化する方法は他にもあるのでしょうか?答えはイエスですが、注視すべきことです!
この記事では、Pythonプログラムに導入できる別のモデルを見てみましょう。モンテカルロ・シミュレータは、アルゴリズム取引のテストだけでなく、オプション価格のようなタイプの様々なパラメータのシミュレーションを行うために使用できます。
Python
マルチコアマシンでは、マルチスレッドであることを期待するコードは余分なコアを使用し、全体的なパフォーマンスを向上させます。残念ながら、メインの Python インタプリタは内部的にはマルチスレッドではありません。
GILが必要なのは、Pythonインタプリタが非スレッドセーフだからです。つまり、スレッド内から Python オブジェクトに安全にアクセスしようとすると、グローバルにロックがかかります。Python 命令の 100 バイトごとにインタプリタがロックを再取得し、I/0 操作をブロックします。ロックのせいで、CPU負荷の高いコードがスレッド化されたライブラリを使っても性能は上がりませんが、マルチプロセッシングライブラリを使うと性能は上がります。
並列ライブラリの実装
さて、上記の2つのライブラリを使用して、「小さな」問題に対する並行最適化を実装します。
スレッドライブラリー
前述のように、CPythonインタプリタを実行するPythonはマルチスレッドによるマルチコア処理をサポートしません。しかし、Pythonにはスレッドライブラリーあります。では、マルチコアを処理に使えないのであれば、このライブラリを使うことでどのようなメリットが得られるのでしょうか?
多くのプログラム、特にネットワーク通信やデータの入出力に関連するプログラムは、ネットワークの性能や入出力の性能によって制限されることがよくあります。Python インタプリタは、ネットワークアドレスやハードドライブなどの「リモート」ソースからデータを読み書きする関数呼び出しの返事を待ちます。その結果、そのようなデータアクセスはローカルメモリや CPU バッファからの読み込みよりもずっと遅くなります。
したがって、この方法で多くのデータソースにアクセスする場合、このデータアクセスのパフォーマンスを向上させる1つの方法は、アクセスする必要があるデータ項目ごとにスレッドを生成することです。
例えば、多くのサイトからURLをスリ取るためのPythonコードがあるとします。さらに、各URLをダウンロードするのに必要な時間が、コンピュータのCPUが処理できる時間よりもはるかに長いと仮定すると、これを実装するために1つのスレッドだけを使用することは、入出力のパフォーマンスによって大きく制限されることになります。
ダウンロードされるリソースごとに新しいスレッドを生成することで、このコードは複数のデータソースを並行してダウンロードし、すべてのダウンロードが終了した時点で結果を結合します。これは、後続の各ダウンロードが前のページのダウンロード終了を待たないことを意味します。この時点で、このコードは受信したクライアント/サーバーの帯域幅によって制限されます。
しかし、金融関連のアプリケーションの多くは、数値の処理が高度に集中化されているため、CPUの性能によって制限されています。そのようなアプリケーションでは、大規模な線形代数計算や、モンテカルロシミュレーション統計の実行など、値に対する確率統計が実行されます。そのため、そのようなアプリケーションでPythonとグローバルインタープリタロックを使用する限り、現時点ではPythonスレッドライブラリを使用しても性能は向上しません。
Pythonの実装
次の「おもちゃの」コードは、リストに数字を順次追加していくもので、マルチスレッド実装の一例です。各スレッドは新しいリストを作成し、乱数をリストに追加します。この選択された「おもちゃ」の例は非常にCPU集約的です。
以下のコードは、スレッディング・ライブラリへのインターフェースの概要を示したものですが、シングルスレッドで実装した場合よりも速くなるわけではありません。以下のコードにマルチプロセッシング・ライブラリを使用すると、総実行時間が大幅に短縮されることがわかります。
このコードがどのように動くか見てみましょう。まず、スレッディング・ライブラリをインポートします。最初の引数countは作成するリストのサイズを定義します。2番目の引数idは "job "のIDで、3番目の引数out_listは追加する乱数のリストです。
main__関数は107のサイズを作成し、2つのスレッドで作業を実行します。次に、切り離されたスレッドを保存するためにジョブのリストが作成されます。threading.Threadオブジェクトはlist_append関数を引数として受け取り、ジョブのリストに追加します。
最後に、ジョブは個別に開始され、「結合」されます。join() メソッドは、呼び出し元のスレッドを終了するまでブロックします。すべてのスレッドが実行を終了したことを確認する完全なメッセージがコンソールに出力されます。
# thread_test.pyimport randomimport threadingdef list_append(count, id, out_list):
"""
Creates an empty list and then appends a
random number to the list 'count' number
of times. A CPU-heavy operation!
"""
for i in range(count):
out_list.append(random.random())if __name__ == "__main__":
size = 10000000 # Number of random numbers to add
threads = 2 # Number of threads to create
# Create a list of jobs and then iterate through
# the number of threads appending each thread to
# the job list
jobs = []
for i in range(0, threads):
out_list = list()
thread = threading.Thread(target=list_append(size, i, out_list))
jobs.append(thread)
# Start the threads (i.e. calculate the random number lists)
for j in jobs:
j.start()
# Ensure all of the threads have finished
for j in jobs:
j.join()
print "List processing complete."
このコードは、コンソールから以下のコマンドで呼び出すことができます。
time python thread_test.py
次のような出力が得られます。
List processing complete.
real 0m2.003s
user 0m1.838s
sys 0m0.161s
ユーザータイムとシステムタイムを足すと、実時間とほぼ等しくなることに注意してください。このことは、スレッド化されたライブラリを使っても性能は上がらないことを示唆しています。実時間の大幅な短縮を期待してください。並行プログラミングにおけるこれらの概念は、それぞれCPU時間とウォールクロック時間と呼ばれます。
マルチプロセッシング・ライブラリ
最新のプロセッサーで利用可能なマルチコアをフル活用するために、マルチプロセッシング・ライブラリ 使用されます。これはスレッディング・ライブラリとは全く異なる方法で動作しますが、2つのライブラリの構文は非常によく似ています。
マルチプロセッシングライブラリは、実際には各並列タスクに対して複数のOSプロセスを生成します。各プロセスに個別の Python インタプリタと個別のグローバルな解釈ロックを与えることで、グローバルな解釈ロックに関連する問題をうまく回避できます。さらに、各プロセスは別々のプロセッサコアを占有することができ、すべてのプロセスが処理を終えたときに結果を再編成することができます。
しかし、いくつかの欠点もあります。複数のプロセッサによるデータ処理がデータの乱雑さを引き起こす可能性があるためです。これは全体的な実行時間の増加につながります。しかし、データを各プロセスに限定することを前提にすれば、パフォーマンスを大幅に向上させることは可能です。もちろん、いくら改善してもアムダールの法則定める限界を超えることはありません。
Pythonの実装
マルチプロセシングを使用した実装では、インポート行とマルチプロセシングの変更のみが必要です。ターゲット関数へのパラメータはここで別途渡します。それ以外はThreadingの実装とほとんど同じです:
# multiproc_test.pyimport randomimport multiprocessingdef list_append(count, id, out_list):
"""
Creates an empty list and then appends a
random number to the list 'count' number
of times. A CPU-heavy operation!
"""
for i in range(count):
out_list.append(random.random())if __name__ == "__main__":
size = 10000000 # Number of random numbers to add
procs = 2 # Number of processes to create
# Create a list of jobs and then iterate through
# the number of processes appending each process to
# the job list
jobs = []
for i in range(0, procs):
out_list = list()
process = multiprocessing.Process(target=list_append,
args=(size, i, out_list))
jobs.append(process)
# Start the processes (i.e. calculate the random number lists)
for j in jobs:
j.start()
# Ensure all of the processes have finished
for j in jobs:
j.join()
print "List processing complete."
コンソールテストの実行時間:
time python multiproc_test.py
次のような出力が得られます:
List processing complete.
real 0m1.045s
user 0m1.824s
sys 0m0.231s
この例では、userとsysの時間は基本的に同じですが、realの時間はほぼ2分の1に低下していることがわかります。これは、2つのプロセスが使用されているために起こります。プロセスを4つに拡張したり、リストの長さを半分にすると、次のようになります:
List processing complete.
real 0m0.540s
user 0m1.792s
sys 0m0.269s
4つのプロセスを使用することで、スピードは約3.8倍に向上しました。しかし、このルールをより大規模で複雑なプログラムに一般化するには注意が必要です。データ変換、ハードウェアのカチャ・レベル、その他多くの問題がスピードアップの妨げになる可能性があります。
関連記事
PythonとNumPyによるコレスキー分解
Pythonによるヨーロピアン・バニラ・コール・プット・オプションのプライシング
PythonとNumPyのヤコビ法
PythonとNumPyによるLU分解
Pythonでオプション価格決定
PythonとNumPyによるQR分解
Ubuntu 14.04でのPython定量調査環境のクイックスタート