Pythonのパフォーマンス最適化

多くの産業において、拡大するニーズに対応するための資源は決して十分ではありません。テクノロジーは生活をより快適で便利なものにし、時とともに進化し、より良いものにしていくことができる。

このようなテクノロジーへの依存の高まりは、利用可能なコンピューティングリソースを犠牲にしています。その結果、より強力なコンピューターが開発され、コードの最適化がかつてないほど重要なものとなっています。

アプリケーションの性能要求は、ハードウェアが追いつく以上に高まっているのです。この問題に対処するため、人々は、コンテナ化、リアクティブ(非同期)アプリケーションなど、リソースをより効率的に利用するためのさまざまな戦略を考え出してきました。

しかし、私たちが取るべき最初のステップ、そして圧倒的に考慮しやすいのは、コードの最適化です。より良いパフォーマンスで、より少ないコンピューティングリソースを使用するコードを書く必要があります。

この記事では、Pythonプログラミングの一般的なパターンと手順を最適化することで、パフォーマンスを向上させ、利用可能なコンピューティングリソースの利用を強化することを目的としています。

パフォーマンスに関する問題

ソフトウェアソリューションがスケールするにつれて、パフォーマンスがより重要になり、問題がより大きく、目に見えるようになります。ローカルホストでコードを書いているときは、使用頻度が高くないので、パフォーマンスの問題を見逃しがちです。しかし、同じソフトウェアが何千、何十万ものエンドユーザーに対してデプロイされるようになると、問題はより深刻になります。

ソフトウェアが拡張されたときに忍び寄る主要な問題の1つが「遅さ」です。これは、応答時間が長くなることが特徴です。たとえば、ウェブサーバーは、要求が多くなると、ウェブページを提供したり、クライアントに応答を返したりするのに時間がかかることがあります。特に、テクノロジーは特定の操作をより速くするためのものであり、システムが遅いと使い勝手が悪くなります。

ソフトウェアが利用可能なリソースをうまく活用できるように最適化されていない場合、スムーズに動作させるために、より多くのリソースを必要とすることになります。例えば、メモリ管理がうまくいっていない場合、プログラムはより多くのメモリを必要とするようになり、その結果、アップグレードのコストがかかったり、頻繁にクラッシュしたりするようになります。

また、最適化されていないプログラムでは、出力に一貫性がなく、誤った結果を出すこともあります。これらの点から、プログラムの最適化が必要であることがわかります。

最適化する理由とタイミング

大規模な使用を想定して構築する場合、最適化はソフトウェアの重要な側面として考慮されます。最適化されたソフトウェアは、多数の同時ユーザーやリクエストを処理しながら、スピードの面で簡単にパフォーマンスレベルを維持することができます。

これは、使用状況に影響を与えないため、全体的な顧客満足につながります。また、夜中にアプリケーションがクラッシュし、怒ったマネージャーから即座に修理の依頼があった場合にも、頭痛の種を減らすことができます。

コンピューティングリソースは高価であり、最適化は、ストレージ、メモリ、またはコンピューティングパワーの観点から、運用コストを削減するのに便利です。

しかし、どのような場合に最適化するのでしょうか?

最適化は、コードベースをより複雑にすることで、可読性と保守性に悪影響を及ぼす可能性があることに注意することが重要です。したがって、最適化の結果を、それがもたらす技術的負債と照らし合わせて検討することが重要です。

もし、エンドユーザーとの対話を多く想定した大規模なシステムを構築するのであれば、システムを最高の状態で動作させる必要があり、そのためには最適化が必要です。また、計算能力やメモリなどのリソースが限られている場合、最適化によって、利用可能なリソースを有効に活用することができます。

プロファイリング

コードを最適化する前に、それが動作している必要があります。そうすれば、そのコードがどのように動作し、どのようにリソースを利用しているかがわかります。そこで、最適化の最初のルールである「やってはいけない」を紹介します。

数学者、コンピュータ科学者、スタンフォード大学教授であるドナルド・クヌースは、次のように言っています。

と言っています。
早すぎる最適化は、諸悪の根源である。

最適化されるためには、ソリューションが機能しなければなりません。

プロファイリングは、コードを精査し、そのパフォーマンスを分析することで、さまざまな状況でのコードのパフォーマンスと、必要に応じて改善すべき領域を特定するものです。プロファイリングによって、プログラムが処理に要する時間や使用するメモリの量を特定することができます。この情報は、コードを最適化するかどうかを決定するのに役立つので、最適化プロセスには欠かせません。

プロファイリングは難しい作業で、多くの時間がかかります。このため、コードのプロファイリングをより速く、より効率的に行うための様々なツールが用意されています。

  • PyCallGraph – Pythonコードのサブルーチン間の呼び出し関係を表すコールグラフの視覚化を作成するものです。
  • cProfile – Pythonコードの様々な部分がどれくらいの頻度でどれくらいの時間実行されるかを記述するものです。
  • gProf2dot – プロファイラの出力をドットグラフに視覚化するライブラリです。

プロファイリングは、私たちのコードで最適化すべき領域を特定するのに役立ちます。正しいデータ構造や制御フローを選択することで、どのようにPythonコードのパフォーマンスを向上させることができるかを説明しましょう。

データ構造の選択と制御フロー

コード内のデータ構造の選択や実装されたアルゴリズムは、Pythonコードの性能に影響を与えます。データ構造を正しく選択すれば、コードの性能は向上します。

プロファイリングは、Pythonコードのさまざまなポイントで使用する最適なデータ構造を特定するのに非常に役立ちます。挿入が多いのか?頻繁に削除しているのか?常に項目を検索しているのか?このような質問は、必要性に応じて正しいデータ構造を選択する指針になり、結果として最適化されたPythonコードになります。

時間やメモリの使用量は、データ構造の選択によって大きく左右されます。また、データ構造の中には、プログラミング言語によって実装が異なるものがあることにも注意が必要です。

For Loopとリスト内包の比較

Pythonで開発する場合、ループは一般的で、すぐにリスト内包を目にすることになると思います。

例えば、ある範囲にあるすべての偶数の2乗のリストを取得するために for ループ を使用するとします。

new_list = []
for n in range(0, 10):
    if n % 2 == 0:
        new_list.append(n**2)


このループの List Comprehension 版は単純に次のようになります。

new_list = [ n**2 for n in range(0,10) if n%2 == 0]


リスト内包はより短く、より簡潔ですが、それだけがトリックではありません。リスト内包はforループよりも実行速度が速いのです。ここでは、Pythonコードの小さな断片を時間計測する方法を提供するTimeitモジュールを使用します。

リスト内包を同等のforループと比較し、同じ結果を得るのにどれくらいの時間がかかるか見てみましょう。

import timeit


def for_square(n):
    new_list = []
    for i in range(0, n):
        if i % 2 == 0:
            new_list.append(n**2)
    return new_list


def list_comp_square(n):
    return [i**2 for i in range(0, n) if i % 2 == 0]


print("Time taken by For Loop: {}".format(timeit.timeit('for_square(10)', 'from __main__ import for_square')))


print("Time taken by List Comprehension: {}".format(timeit.timeit('list_comp_square(10)', 'from __main__ import list_comp_square')))


Python 2でこのスクリプトを5回実行すると、次のようになります。

$ python for-vs-lc.py 
Time taken by For Loop: 2.56907987595
Time taken by List Comprehension: 2.01556396484
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 2.37083697319
Time taken by List Comprehension: 1.94110512733
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 2.52163410187
Time taken by List Comprehension: 1.96427607536
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 2.44279003143
Time taken by List Comprehension: 2.16282701492
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 2.63641500473
Time taken by List Comprehension: 1.90950393677


この差は一定ではありませんが、リスト内包の方が for ループよりも時間がかかっています。小規模なコードでは、この差はそれほど大きくないかもしれませんが、大規模な実行では、時間を節約するために必要なすべての差になるかもしれません。

平方根の範囲を10~100まで広げると、その差はより明らかになる。

$ python for-vs-lc.py 
Time taken by For Loop: 16.0991549492
Time taken by List Comprehension: 13.9700510502
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 16.6425571442
Time taken by List Comprehension: 13.4352738857
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 16.2476081848
Time taken by List Comprehension: 13.2488780022
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 15.9152050018
Time taken by List Comprehension: 13.3579590321


cProfileはPythonに付属しているプロファイラで、これを使ってコードのプロファイリングをすると。

cProfileは、呼び出されたすべての関数、呼び出された回数、そしてそれぞれの関数にかかった時間を表示します。

もし、コードの実行時間を短縮することが目的であれば、For Loopを使うよりもList Comprehensionを使う方が良い選択でしょう。このような最適化の判断は、より大きなスケールで見ればその効果は明らかで、コードの最適化がいかに重要であり、また容易であるかを示しています。

しかし、メモリ使用量を気にする場合はどうでしょうか。リスト内包は、通常のループよりもリスト内の項目を削除するために多くのメモリを必要とします。リスト内包は完了時に常にメモリ内に新しいリストを作成するので、リストから項目を削除するために新しいリストが作成されます。一方、通常の for ループでは、メモリ上に新しいリストを作成する代わりに、 list.remove()list.pop() を使って元のリストを変更することができます。

繰り返しになりますが、小規模なスクリプトではあまり違いはないかもしれませんが、大規模なスクリプトでは最適化は有効で、そのような状況では、このようなメモリ節約は有効で、節約した余分なメモリを他の処理に使用することが可能になります。

リンクリスト

メモリを節約するのに便利なもうひとつのデータ構造に、リンクリストがあります。これは通常の配列とは異なり、各項目やノードがリスト内の次のノードへのリンクやポインタを持ち、連続したメモリ割り当てを必要としないのが特徴です。

配列では、配列とその項目を格納するために必要なメモリを前もって確保する必要があり、配列のサイズが事前にわからない場合には、非常にコストがかかったり、無駄が生じたりすることがあります。

リンクリストであれば、必要に応じてメモリを確保することができます。これは、リンクリストのノードがメモリ上の異なる場所に格納されていても、ポインタを通じてリンクリストに集まってくるからです。このため、リンクリストは配列と比較して、より柔軟性があります。

リンクリストの注意点は、メモリ内のアイテムの配置により、ルックアップ時間が配列よりも遅くなることです。適切なプロファイリングは、コードを最適化する際にデータ構造としてリンクリストと配列のどちらを使用するかを決定するために、より良いメモリまたは時間管理が必要かを識別するのに役立ちます。

レンジとXレンジの比較

Python でループを処理するとき、for ループの実行を補助するために整数のリストを生成する必要があることがあります。このような場合に、関数 rangexrange が使用されます。

これらの関数は同じものですが、 rangelist オブジェクトを返すのに対して、 xrangexrange オブジェクトを返すという違いがあります。

これはどういう意味でしょうか?xrange` オブジェクトは最終的なリストではないという点で、ジェネレータと言えます。これは “イールド” として知られるテクニックで、実行時に必要に応じて期待される最終的なリストの値を生成する機能を提供します。

関数 xrange が最終的なリストを返さないという事実は、ループ処理のために巨大な整数リストを生成する場合、よりメモリ効率の良い選択となります。

もし、大量の整数を生成して使用する必要がある場合は、メモリ使用量が少ない xrange を選択すべきです。代わりに range 関数を使用すると、整数のリスト全体を作成する必要があり、メモリを大量に消費することになります。

それでは、この2つの関数のメモリ消費量の違いを見てみましょう。

$ python
Python 2.7.10 (default, Oct 23 2015, 19:19:21) 
[GCC 4.2.1 Compatible Apple LLVM 7.0.0 (clang-700.0.59.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> 
>>> r = range(1000000)
>>> x = xrange(1000000)
>>> 
>>> print(sys.getsizeof(r))
8000072
>>> 
>>> print(sys.getsizeof(x))
40
>>> 
>>> print(type(r))
<type 'list'=""
&gt;&gt;&gt; print(type(x))
<type 'xrange'=""


ここでは、 rangexrange を使って 1,000,000 個の整数からなる範囲を作成します。range関数で生成されるオブジェクトはListで、8000072 bytesのメモリを消費するのに対し、xrangeオブジェクトは40 bytes` のメモリを消費するだけです。

このように xrange 関数はメモリを大量に消費しますが、アイテムのルックアップ時間はどうでしょうか?Timeit を使って、生成された整数のリストで整数を探す時間を計ってみましょう。

import timeit


r = range(1000000)
x = xrange(1000000)


def lookup_range():
    return r[999999]


def lookup_xrange():
    return x[999999]


print("Look up time in Range: {}".format(timeit.timeit('lookup_range()', 'from __main__ import lookup_range')))


print("Look up time in Xrange: {}".format(timeit.timeit('lookup_xrange()', 'from __main__ import lookup_xrange')))


その結果

$ python range-vs-xrange.py 
Look up time in Range: 0.0959858894348
Look up time in Xrange: 0.140854120255
$ 
$ python range-vs-xrange.py 
Look up time in Range: 0.111716985703
Look up time in Xrange: 0.130584001541
$ 
$ python range-vs-xrange.py 
Look up time in Range: 0.110965013504
Look up time in Xrange: 0.133008003235
$ 
$ python range-vs-xrange.py 
Look up time in Range: 0.102388143539
Look up time in Xrange: 0.133061170578


xrangeはより少ないメモリを消費するかもしれませんが、その中の項目を探すのにより多くの時間がかかります。このような状況や利用可能なリソースを考えると、目指す方向性に応じてrangexrange` のどちらかを選択することができます。このことは、Pythonコードの最適化におけるプロファイリングの重要性を再認識させます。

Note: xrange は Python 3 で非推奨となり、 range 関数が同じ機能を提供するようになりました。ジェネレータはPython 3でも利用可能で、ジェネレータの内包や式のような他の方法でメモリを節約することができます。

セット

PythonでListを扱うとき、Listが重複したエントリを許すことに注意する必要があります。もし、データに重複があるかどうかが重要だとしたらどうでしょうか?

ここでPythonのSetsが登場します。これはリストに似ていますが、重複を許しません。セットは、Listから重複を効率的に削除するためにも使われ、新しいListを作成し、重複のあるものからそれを入力するよりも高速になります。

この操作では、セットを、重複を抑えて一意な値だけを通過させる漏斗またはフィルターのように考えることができます。

この2つの操作を比較してみましょう。

import timeit


# here we create a new list and add the elements one by one
# while checking for duplicates
def manual_remove_duplicates(list_of_duplicates):
    new_list = []
    [new_list.append(n) for n in list_of_duplicates if n not in new_list]
    return new_list


# using a set is as simple as
def set_remove_duplicates(list_of_duplicates):
    return list(set(list_of_duplicates))


list_of_duplicates = [10, 54, 76, 10, 54, 100, 1991, 6782, 1991, 1991, 64, 10]


print("Manually removing duplicates takes {}s".format(timeit.timeit('manual_remove_duplicates(list_of_duplicates)', 'from __main__ import manual_remove_duplicates, list_of_duplicates')))


print("Using Set to remove duplicates takes {}s".format(timeit.timeit('set_remove_duplicates(list_of_duplicates)', 'from __main__ import set_remove_duplicates, list_of_duplicates')))


スクリプトを5回実行した後

$ python sets-vs-lists.py 
Manually removing duplicates takes 2.64614701271s
Using Set to remove duplicates takes 2.23225092888s
$ 
$ python sets-vs-lists.py 
Manually removing duplicates takes 2.65356898308s
Using Set to remove duplicates takes 1.1165189743s
$ 
$ python sets-vs-lists.py 
Manually removing duplicates takes 2.53129696846s
Using Set to remove duplicates takes 1.15646100044s
$ 
$ python sets-vs-lists.py 
Manually removing duplicates takes 2.57102680206s
Using Set to remove duplicates takes 1.13189387321s
$ 
$ python sets-vs-lists.py 
Manually removing duplicates takes 2.48338890076s
Using Set to remove duplicates takes 1.20611810684s


セットを使用して重複を削除することは、手動でリストを作成し、存在を確認しながら項目を追加するよりも常に高速です。

これは、景品コンテストのエントリーをフィルタリングする際に、重複するエントリーを除外するのに便利です。120のエントリーをフィルタリングするのに2秒かかるとしたら、1万のエントリーをフィルタリングすることを想像してみてください。このような規模では、Setsによってもたらされるパフォーマンスの大幅な向上は重要です。

このようなことはあまり起こらないかもしれませんが、要求されたときに大きな違いを生む可能性があります。適切なプロファイリングは、そのような状況を特定するのに役立ち、コードの性能に大きな違いをもたらすことができます。

文字列の連結

Pythonでは文字列はデフォルトで不変であり、そのため文字列の連結は非常に遅くなることがあります。文字列の連結にはいくつかの方法があり、様々な状況に適用できます。

文字列の連結には + (プラス) を使うことができます。これは少数の String オブジェクトに最適で、規模が大きくない場合です。複数の文字列を連結するために + 演算子を使用した場合、String は不変なので、連結するたびに新しいオブジェクトが作成されます。その結果、メモリ上に多くの新しいStringオブジェクトが作成され、メモリの利用が不適切になります。

連結演算子 += を使って文字列を連結することもできますが、2つ以上の文字列を連結できる + 演算子とは異なり、これは一度に2つの文字列に対してのみ有効です。

リストなどのイテレータに複数の文字列がある場合、それらを結合するには .join() メソッドを使用するのが理想的です。

1000語のリストを作成して、 .join()+= 演算子を比較する方法を考えてみましょう。

import timeit


# create a list of 1000 words
list_of_words = ["foo "] * 1000


def using_join(list_of_words):
    return "".join(list_of_words)


def using_concat_operator(list_of_words):
    final_string = ""
    for i in list_of_words:
        final_string += i
    return final_string


print("Using join() takes {} s".format(timeit.timeit('using_join(list_of_words)', 'from __main__ import using_join, list_of_words')))


print("Using += takes {} s".format(timeit.timeit('using_concat_operator(list_of_words)', 'from __main__ import using_concat_operator, list_of_words')))


2回試した結果

$ python join-vs-concat.py 
Using join() takes 14.0949640274 s
Using += takes 79.5631570816 s
$ 
$ python join-vs-concat.py 
Using join() takes 13.3542580605 s
Using += takes 76.3233859539 s


イテレータで文字列を結合する場合、 .join() メソッドはよりすっきりとして読みやすいだけでなく、連結演算子よりもかなり高速であることがわかります。

もしあなたが多くのStringの連結操作を行うのであれば、7倍近くも速いアプローチのメリットを享受できるのは素晴らしいことです。

結論

Pythonではコードの最適化が重要であることがわかり、また、スケールしたときの違いも確認できました。TimeitモジュールとcProfileプロファイラにより、どの実装がより短い時間で実行されるかがわかり、それを数値で裏付けました。使用するデータ構造や制御フローの構造は、コードのパフォーマンスに大きく影響するため、より慎重になる必要があります。

プロファイリングは、最適化プロセスを導き、より正確なものにするため、コードの最適化においても重要なステップです。最適化する前に、コードが動作し、正しいことを確認する必要があります。早すぎる最適化は、維持費が高くなったり、コードが理解しにくくなったりする可能性があります。

</type

タイトルとURLをコピーしました