本記事は『爆速Python』の「Chapter 2 組み込み機能のパフォーマンスを最大限に引き出す」から一部を抜粋したものです。掲載にあたって編集しています。
※本書はTiago Rodrigues Antãoによる『Fast Python: High performance techniques for large datasets』(Manning Publications)の邦訳です。
組み込み機能のパフォーマンスを最大限に引き出す
より効率的なPythonコードを記述するのに役立つツールやライブラリはたくさんあります。しかし、パフォーマンスを向上させる外部の選択肢をすべて見ていく前に、コンピューティングとI/Oパフォーマンスの両面でより効率のよいピュアPythonコードの書き方を詳しく調べてみましょう。実際、Pythonのパフォーマンス問題の多くは、Pythonの制限と機能にもう少し注意すれば解決できるものばかりです(もちろん、すべてがそうというわけではありません)。
パフォーマンスを向上させるPython独自のツールを具体的に理解するために、架空ではあるものの、現実的な問題にそれらを使ってみましょう。データエンジニアをしていて、世界中の気候データの分析の準備を任されているとしましょう。気候データはアメリカ海洋大気庁(NOAA)のものを使うことになっています。期日が迫っているため、使えるのはほぼ標準的なPythonだけになりそうです。
さらに、予算も限られているため、コンピュータの買い足しは問題外です。データが届き始めるまでひと月あるため、データが到着するまでの時間をコードのパフォーマンスの改善に充てるつもりです。したがって、最適化が必要な場所を見つけ出し、そのパフォーマンスを向上させることが、あなたの役目となります。
あなたはまず、データを取り込む既存のコードのプロファイリングを実行したいと考えます。そのコードが低速であることはわかっていますが、最適化を試みる前に、ボトルネックの経験的証拠を突き止める必要があります。コードのボトルネックを厳密かつ体系的に調査できる点で、プロファイリングは重要です。プロファイリングの代わりに最もよく使われるのは当て推量ですが、多くの減速ポイントはかなり直観に反するものでありで、ここではまったく役立ちません。
ピュアPythonのコード最適化は達成しやすい目標ですが、ほとんどの問題もそこに存在する傾向にあるため、しばしば大きな効果が期待できます。ここでは、ピュアPythonに最初から組み込まれている機能のうち、より効率的なコードの開発に役立つものを調べます。まず、何種類かのプロファイリングツールを使ってコードのプロファイリングを行い、問題がある領域を特定します。
次に、Pythonの基本的なデータ構造であるリスト、セット、ディクショナリを重点的に見ていきます。ここでの目標は、これらのデータ構造の効率を改善することと、最適なパフォーマンスを達成するのに最も効果的な方法でそれらのデータ構造のメモリを確保することです。さらに、現代のPythonの遅延プログラミングテクニックがデータパイプラインのパフォーマンスの改善にどのように役立つのかを確認します。
外部ライブラリを使わずにPythonを最適化する方法だけを説明しますが、パフォーマンスの最適化とデータアクセスに役立つ外部ツールをいくつか使います。SnakeVizを使ってPythonのプロファイリングの出力を可視化し、line_profilerを使ってコードを1行ずつプロファイリングします。最後に、requestsライブラリを使ってデータをインターネットからダウンロードします。
Dockerを使う場合、必要なものはすべてデフォルトのイメージに含まれています。それでは、気象観測所からデータをダウンロードし、各観測所の気温を調べることで、プロファイリングを開始することにします。
I/Oワークロードとコンピューティングワークロードに基づくアプリケーションのプロファイリング
最初の目標は、気象観測所からデータをダウンロードし、その観測所の特定の年の最低気温を突き止めることです。NOAAサイトのデータは、年別に、観測所ごとのCSVファイルに含まれています。
たとえば、https://www.ncei.noaa.gov/data/global-hourly/access/2021/01494099999.csvファイルには、2021年の観測所01494099999のエントリがすべて含まれています。さまざまなエントリの中に気温と気圧が含まれており、1日に数回記録されている可能性があります。
一連の観測所の数年分のデータをダウンロードするスクリプトを開発してみましょう。対象となるデータをダウンロードした後、各観測所の最低気温を突き止めます。
データのダウンロードと最低気温の算出
このスクリプトには単純なコマンドラインインターフェイス(CLI)があり、このCLIに観測所のリストと対象となる年の範囲を渡します。入力を解析するコードは次のようになります(このコードは02-python/sec1-io-cpu/load.pyに含まれています)。
import collections import csv import datetime import sys import requests stations = sys.argv[1].split(",") years = [int(year) for year in sys.argv[2].split("-")] start_year = years[0] end_year = years[1]
コーディングを容易にするために、ファイルの取得にはrequestsライブラリを使うことにします。サーバーからデータをダウンロードするコードは次のようになります。
TEMPLATE_URL = 'https://www.ncei.noaa.gov/data/global-hourly/access/'\ '{year}/{station}.csv' TEMPLATE_FILE = 'station_{station}_{year}.csv' def download_data(station, year): my_url = TEMPLATE_URL.format(station=station, year=year) # requestsを使うとWebコンテンツに簡単にアクセスできる req = requests.get(my_url) if req.status_code != 200: return # データが見つからなかった w = open(TEMPLATE_FILE.format(station=station, year=year), "wt") w.write(req.text) w.close() def download_all_data(stations, start_year, end_year): for station in stations: for year in range(start_year, end_year + 1): download_data(station, year)
このコードは、リクエストされたすべての観測所のデータをすべての年にわたってダウンロードし、それぞれのファイルをディスクに書き込みます。次に、すべての気温データを1つのファイルにまとめます。
def get_file_temperatures(file_name): with open(file_name, "rt") as f: reader = csv.reader(f) header = next(reader) for row in reader: station = row[header.index("STATI/ON")] # date = datetime.datetime.fromisoformat(row[header.index('DATE')]) tmp = row[header.index("TMP")] # 気温フィールドには、データのステータスに関するサブフィールドがある temperature, status = tmp.split(",") # データがない場合はエントリを無視する if status != "1": continue temperature = int(temperature) / 10 yield temperature
次に、観測所ごとにすべての気温と最低温度を取得します。
def get_all_temperatures(stations, start_year, end_year): temperatures = collections.defaultdict(list) for station in stations: for year in range(start_year, end_year + 1): for temperature in get_file_temperatures( TEMPLATE_FILE.format(station=station, year=year)): temperatures[station].append(temperature) return temperatures def get_min_temperatures(all_temperatures): return {station: min(temperatures) for station, temperatures in all_temperatures.items()}
ここまでのコードをまとめて、データをダウンロードし、すべての気温を取得し、観測所ごとの最低気温を計算し、結果を出力します。
download_all_data(stations, start_year, end_year) all_temperatures = get_all_temperatures(stations, start_year, end_year) min_temperatures = get_min_temperatures(all_temperatures) print(min_temperatures)
たとえば、観測所01044099999と02293099999の2021年のデータをダウンロードするには、次のコマンドを実行します。
$ python load.py 01044099999,02293099999 2021-2021
出力は次のようになります。
{'01044099999': -10.0, '02293099999': -27.6}
さて、本当におもしろくなるのはここからです。ここでの目標は、多くの観測所が長年にわたって観測してきた大量のデータを引き続きダウンロードすることです。これだけの量のデータを扱うわけですから、コードをできるだけ効率化したいところです。
コードを効率化するための最初のステップは、コードのプロファイリングを組織的かつ徹底的に行うことで、パフォーマンスの妨げになっているボトルネックを特定することです。この作業には、Pythonに組み込まれているプロファイリングメカニズムを使うことにします。
Pythonの組み込みのプロファイリングモジュール
コードをできるだけ効率化するために必要な最初の作業は、そのコードに存在するボトルネックを見つけ出すことです。まず、コードのプロファイリングを行い、各関数の所要時間を調べる必要があります。
そこで、PythonのcProfileモジュールを使ってコードを実行します。このモジュールはPythonに組み込まれており、このモジュールを使ってコードのプロファイリング情報を取得できます。なお、profileモジュールは桁違いに低速なので使わないようにしてください。このモジュールが役立つのは、プロファイリングツールを自分で開発している場合だけです。
このプロファイラは次のように実行できます。
$ python -m cProfile -s cumulative load.py 01044099999,02293099999 2021-2021 \ > > profile.txt
-mフラグを使ってPythonを実行するとモジュールが実行されることを覚えておいてください。つまり、この場合はcProfileモジュールが実行されます。cProfileはプロファイリング情報の収集に推奨されるPythonのモジュールです。次に示すように、この場合はプロファイリングの統計データを累積時間の順に要求しています。このモジュールの最も簡単な使い方は、このように、モジュール呼び出しでプロファイラにスクリプトを渡すことです。
出力は累積時間の順に並んでいます。累積時間とは、特定の関数内で費やされた合計時間のことです。また、関数ごとに呼び出しの回数も出力されています。
たとえば、すべてのデータのダウンロードを処理するdownload_all_data()の呼び出しは1回だけですが、その累積時間はスクリプトの合計時間とほぼ同じです。percall列が2つあることに気付いたでしょうか。
1つ目は、その関数に費やされた時間からすべての内部呼び出しに費やされた時間を除いたものです。2つ目は、内部呼び出しに費やされた時間です。download_all_data()の場合は、この関数から呼び出されるいくつかの関数でほとんどの時間が費やされていることがわかります。
多くの場合、このようにI/Oが集中的に発生しているときは、かかった時間の大半をI/Oが占めていると見てよいでしょう。このケースでは、ネットワークI/O(NOAAからデータを取得)とディスクI/O(ディスクへの書き込み)の両方が発生しています。ネットワークのコストは途中にある多くの接続ポイントに依存するため、大きなばらつきがあります(実行ごとに異なるほどです)。時間のロスが最も大きいのはたいていネットワークアクセスであるため、この部分を調整してみましょう。
ローカルキャッシュを使ってネットワークアクセスを減らす
ネットワークアクセスを減らすために、最初にファイルをダウンロードするときにコピーを保存し、後から利用できるようにします。つまり、データのローカルキャッシュを作成します。download_all_data()以外は、先と同じコードを使います(このコードは02-python/sec1-io-cpu/load_cache.pyに含まれています)。
import os def download_all_data(stations, start_year, end_year): for station in stations: for year in range(start_year, end_year + 1): # ファイルがすでに存在するか確認し、存在しない場合のみダウンロード if not os.path.exists(TEMPLATE_FILE. download_data(station, year)
このコードの最初の実行には前項のソリューションと同じ時間がかかりますが、2回目の実行ではネットワークアクセスはまったく必要ありません。たとえば、実行が前回と同じであると仮定した場合、2.8秒が0.26秒に短縮されます。つまり、速度が1桁以上違ってきます。ネットワークアクセスはばらつきが大きいため、ファイルのダウンロードにかかる時間が状況によって大きく異なる可能性があることに注意してください。これはネットワークデータのキャッシュを検討するもう1つの理由であり、実行にかかる時間が予測しやすくなります。
$ python -m cProfile -s cumulative load_cache.py 01044099999,02293099999 \ > 2021-2021 > profile_cache.txt
実行時間は1桁短縮されましたが、I/Oに最も時間がかかっているという状況は変わりません。今回は、ネットワークアクセスではなくディスクアクセスです。その主な原因は、計算量が実際に少なくなったことにあります。
次は、制限要因がCPUであるというケースについて考えてみましょう。
WARNING この例が示すように、キャッシュを使うとコードを桁違いに高速化できます。ただし、キャッシュの管理が問題になることがあり、バグの主な原因になっています。この例では、時間がたってもファイルは変化しませんが、ソースが変化するかもしれない状況でもキャッシュがよく使われます。その場合、キャッシュを管理するコードでは、その問題を認識しておく必要があります。キャッシュについては、本書の他の部分で改めて取り上げます。
パフォーマンスのボトルネックを特定するためのプロファイリング
ここでは、プロセスにおいて最も時間がかかるリソースがCPUであるというコードを調べます。NOAAデータベースのすべての観測所を調べて、それらの距離を計算します。これは計算量N2の問題です。
『爆速Python』のGitHubリポジトリには、各観測所の地理座標がすべて含まれたファイルがあります(次のコードは02-python/sec2-cpu/distance_cache.pyに含まれています)。
import csv import math def get_locations(): with open("locations.csv", "rt") as f: reader = csv.reader(f) header = next(reader) for row in reader: station = row[header.index("STATION")] lat = float(row[header.index("LATITUDE")]) lon = float(row[header.index("LONGITUDE")]) yield station, (lat, lon) # 2つの観測所間の距離を計算するコード def get_distance(p1, p2): lat1, lon1 = p1 lat2, lon2 = p2 lat_dist = math.radians(lat2 - lat1) lon_dist = math.radians(lon2 - lon1) a = ( math.sin(lat_dist / 2) * math.sin(lat_dist / 2) + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(lon_dist / 2) * math.sin(lon_dist / 2) ) c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) earth_radius = 6371 dist = earth_radius * c return dist def get_distances(stations, locations): distances = {} for first_i in range(len(stations) - 1): first_station = stations[first_i] # すべての観測所を相互に比較するため... first_location = locations[first_station] for second_i in range(first_i, len(stations)): second_station = stations[second_i] # ...計算量はN2のオーダーになる second_location = locations[second_station] distances[(first_station, second_station)] = get_distance( first_location, second_location) return distances locations = {station: (lat, lon) for station, (lat, lon) in get_locations()} stations = sorted(locations.keys()) distances = get_distances(stations, locations)
このコードの実行にはかなり時間がかかります。メモリも大量に消費します。メモリに問題がある場合は、処理する観測所の数を制限してください。では、Pythonのプロファイリングインフラを使って、最も時間がかかっている場所を調べてみましょう。
プロファイリング情報を可視化する
ここでも、Pythonのプロファイリングインフラを使って、実行を遅らせているコードを特定します。トレースをより詳しく検査するために、SnakeVizという可視化ツールを使うことにします。
まず、プロファイリングのトレースを保存します。
$ python -m cProfile -o distance_cache.prof distance_cache.py
-oパラメータは、プロファイリング情報を保存するファイルを指定します。その後は、通常どおりにコードを呼び出します。
NOTE Pythonには、ディスクに書き出されたトレースを分析するためのpstatsモジュールがあります。python -m pstats distance_cache.profを実行すると、スクリプトのコストを分析するCLIが起動します。
この情報の分析には、Webベースの可視化ツールであるSnakeVizを使います。といっても、snakeviz distance_cache.profを実行するだけです。そうすると、対話型のブラウザウィンドウが開きます。
SnakeVizのインターフェイスに慣れておく
せっかくの機会なので、SnakeVizのインターフェイスを少し試して慣れておきましょう。たとえば、スタイルを[Icicle]から[Sunburst]に変更できます(そのほうが見栄えはよくなりますが、ファイル名が表示されなくなるため、情報が少なくなります)。一番下の表を並べ替え、[Depth]と[Cutoff]のエントリをチェックしてください。色付きのブロックも忘れずにクリックしてみてください。メインビューに戻るには、[Call Stack]をクリックし、[0]エントリを選択します。
ほとんどの時間はget_distance()関数の内部で費やされていますが、その正確な場所まではわかりません。一部の数学関数のコストは確認できますが、Pythonのプロファイリングでは、各関数の内部で何が行われているのかを細かく把握できるわけではなく、各三角関数の集計が表示されるだけです。
math.sin()で時間がかかっていることは確かですが、この関数を使っている行は複数あります。法外な代価を支払っているのはどの行でしょうか。それを突き止めるには、ラインプロファイリングモジュールに助けてもらう必要があります。
ラインプロファイリング
前項で使ったような組み込みのプロファイリングにより、大幅な遅延の原因となっているコードを見つけ出すことができました。しかし、このプロファイリングを使ってできることには限りがあります。ここでは、これらの制限について説明し、コードのパフォーマンスのボトルネックをさらに詳しく調べるための方法としてラインプロファイリングを紹介します。
get_distance()関数の各行のコストを理解するために、ここではline_profilerパッケージを使います。ラインプロファイラの使い方は非常に簡単で、get_distance()関数にアノテーションを追加する必要があるだけです。
@profile def get_distance(p1, p2):
profileアノテーションをどこからもインポートしていないことに気付いたかもしれません。というのも、line_profilerパッケージの便利なスクリプトkernprofを使うと、このスクリプトがそうした処理をしてくれるからです。では、ラインプロファイラを実行してみましょう。
$ kernprof -l lprofile_distance_cache.py
ラインプロファイラはインストルメンテーションコードを要求するため、実行が桁違いに遅くなることを覚悟しておいてください。1分ほど実行したら、処理を中止してください(kernprofを最後まで実行すると、おそらく何時間もかかります)。処理を中止してもトレースは得られます。プロファイラを終了したら、次のコマンドを使って結果を調べてみましょう。
$ python -m line_profiler lprofile_distance_cache.py.lprof
ラインプロファイラの出力(リスト1)を調べてみると、時間がかかっている呼び出しがい くつもあることがわかります。そのコードを最適化する必要がありそうです。この段階ではプロファイリングだけを説明しているため、作業はここまでにしますが、後でそれらの行を最適 化する必要があります。
line_profilerの出力が、組み込みのプロファイラの出力よりもかなり直観的であることがわかったと思います。
重要なポイント:コードのプロファイリング
ここまで見てきたように、組み込みのプロファイリング全体は最初のアプローチとして申し分なく、ラインプロファイリングよりもずっと高速です。一方で、ラインプロファイリングのほうが圧倒的に情報量が多いこともわかります。
その主な理由は、Pythonの組み込みのプロファイリングでは、関数の内部の詳細が提供されないことにあります。Pythonのプロファイリングは、関数ごとの累積値と、内部での呼び出しにかかった時間を明らかにするだけです。
特定のケースでは、関数の内部から別の関数が呼び出されるかどうかを知ることは可能ですが、一般的には不可能です。全体的なプロファイリング戦略では、これらの点をすべて考慮に入れる必要があります。
ここで使った戦略は、一般的に妥当なアプローチです。最初に、Pythonの組み込みのプロファイリングモジュールであるcProfileを試します。このモジュールは高速で、大まかな情報を提供するからです。それでは不十分な場合は、ラインプロファイリングを使います。ラインプロファイリングのほうがより多くの情報が得られますが、その分時間がかかります。
ここでの主な関心はボトルネックの特定にあることを思い出してください場合によっては、既存のソリューションの一部を変更するだけでは不十分で、アーキテクチャの全体的な見直しが必要になることもあります。