blog

ボスのようにPythonを最適化する

97%の場合、早すぎる最適化は諸悪の根源だと言われています。-- ドナルド・クヌース...

Nov 18, 2015 · 13 min. read
シェア

些細な効率の問題は忘れるべきであり、97%のケースでは、早すぎる最適化が諸悪の根源であると述べられています。-- ドナルド・クヌース

その主な目的は単純で、ボトルネックをできるだけ早く見つけ、それを修正し、確実に修正することです。

テストを書く

最適化を始める前に、元のコードが遅いことを証明するための高度なテストを書いてください。十分に遅いことを再現するために、最小値のデータセットを採用する必要があるかもしれません。通常、ランタイム秒数を示すプログラムが1つか2つあれば、ある程度の改善には十分です。

また、最適化によって元のコードの挙動が変わっていないことを確認するために、 いくつかの基本テストを用意しておくことも不可欠です。また、何度もテストを実行するうちに、これらのテストのベースラインを少しずつ修正し、 コードを最適化できるようになります。

それでは、最適化ツールを見てみましょう。

シンプルタイマー

タイマーはシンプルで、実行時間を記録する最も柔軟な方法の1つです。どこにでも設置でき、副作用も最小限に抑えられます。独自のタイマーを実行するのはとても簡単で、カスタマイズすることで思い通りに動作させることができます。例えば、単純なタイマーは次のようになります:

import time 
  
def timefunc(f): 
    def f_timer(*args, **kwargs): 
        start = time.time() 
        result = f(*args, **kwargs) 
        end = time.time() 
        print f.__name__, 'took', end - start, 'time' 
        return result 
    return f_timer 
  
def get_number(): 
    for x in xrange(5000000): 
        yield x 
  
@timefunc 
def expensive_function(): 
    for x in get_number(): 
        i = x ^ x ^ x 
    return 'some result!' 
  
# prints "expensive_function took 0.72583088875 seconds" 
result = expensive_function() 

もちろん、コンテキスト管理やチェックポイントの追加など、より強力な機能を追加することもできます:

import time 
  
class timewith(): 
    def __init__(self, name=''): 
        self.name = name 
        self.start = time.time() 
  
    @property 
    def elapsed(self): 
        return time.time() - self.start 
  
    def checkpoint(self, name=''): 
        print '{timer} {checkpoint} took {elapsed} seconds'.format( 
            timer=self.name, 
            checkpoint=name, 
            elapsed=self.elapsed, 
        ).strip() 
  
    def __enter__(self): 
        return self 
  
    def __exit__(self, type, value, traceback): 
        self.checkpoint('finished') 
        pass 
  
def get_number(): 
    for x in xrange(5000000): 
        yield x 
  
def expensive_function(): 
    for x in get_number(): 
        i = x ^ x ^ x 
    return 'some result!' 
  
# prints something like: 
# fancy thing done with something took 0.582462072372 seconds 
# fancy thing done with something else took 1.75355315208 seconds 
# fancy thing finished took 1.7535982132 seconds 
with timewith('fancy thing') as timer: 
    expensive_function() 
    timer.checkpoint('done with something') 
    expensive_function() 
    expensive_function() 
    timer.checkpoint('done with something else') 
  
# or directly 
timer = timewith('fancy thing') 
expensive_function() 
timer.checkpoint('done with something') 

タイマーを使うには、少し調べる必要があります。より高いレベルの関数をいくつかラッピングし、ボトルネックがどこにあるかを特定し、それを再現し続けられるように関数を深く掘り下げていきます。適合しないコードが見つかったら、それを修正し、再度テストして修正されていることを確認します。

いくつかのヒント:古き良きtimeitモジュールを忘れないでください!実際の調査よりも、コードの小さな塊をベンチマークするのに便利です。

  • タイマーの 長所:理解も実装も非常に簡単。また、修正後の比較も非常に簡単です。多くの言語に対応。
  • タイマーの 短所:非常に複雑なコードには少しシンプルすぎることがあり、問題を修正するよりも、参照されているコードの配置や移動に多くの時間を費やす可能性があります!

#p#

ビルトインオプティマイザ

内蔵オプティマイザーを有効にすることは、大砲を使うようなものです。非常に強力ですが、少しそうではありませんし、使い方や説明も複雑です。

プロファイル・モジュールについてもっと詳しく知ることができますが、その基本はとてもシンプルです:オプティマイザを有効にしたり無効にしたりでき、すべての関数呼び出しと実行時間を表示します。コンパイルして出力を出力してくれます。簡単なデコレータは次のとおりです:

import cProfile 
  
def do_cprofile(func): 
    def profiled_func(*args, **kwargs): 
        profile = cProfile.Profile() 
        try: 
            profile.enable() 
            result = func(*args, **kwargs) 
            profile.disable() 
            return result 
        finally: 
            profile.print_stats() 
    return profiled_func 
  
def get_number(): 
    for x in xrange(5000000): 
        yield x 
  
@do_cprofile 
def expensive_function(): 
    for x in get_number(): 
        i = x ^ x ^ x 
    return 'some result!' 
  
# perform profiling 
result = expensive_function() 

上記のコードの場合、ターミナルに次のようなものが出力されるはずです:

5000003 function calls in 1.626 seconds 
  
   Ordered by: standard name 
  
   ncalls  tottime  percall  cumtime  percall filename:lineno(function) 
  5000001    0.571    0.000    0.571    0.000 timers.py:92(get_number) 
        1    1.055    1.055    1.626    1.626 timers.py:96(expensive_function) 
        1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects} 

お分かりのように、これは異なる関数の呼び出し回数を示していますが、いくつかの重要な情報が抜け落ちています。

しかし、これは基本的な最適化のための良いスタートです。より少ない労力で解決できることもあります。私はよく、どの関数が遅いのか、あるいは何度も呼ばれすぎているのかを正確に掘り下げる前に、プログラムをデバッグするために使います。

  • ビルトインの利点:追加の依存関係がなく、非常に速い。高レベルのチェックを素早く行うのに非常に便利です。
  • 組み込みの欠点:比較的限られた情報しか得られないため、さらなるデバッグが必要。

ラインプロファイラ

ビルトイン・オプティマイザが大砲だとすれば、ライン・プロファイラーはイオン・キャノン砲のようなものです。非常に重量級で強力です。

この例では、とてもすばらしいline_profilerライブラリを使います。使いやすくするために、デコレータでラップしています。

try: 
    from line_profiler import LineProfiler 
  
    def do_profile(follow=[]): 
        def inner(func): 
            def profiled_func(*args, **kwargs): 
                try: 
                    profiler = LineProfiler() 
                    profiler.add_function(func) 
                    for f in follow: 
                        profiler.add_function(f) 
                    profiler.enable_by_count() 
                    return func(*args, **kwargs) 
                finally: 
                    profiler.print_stats() 
            return profiled_func 
        return inner 
  
except ImportError: 
    def do_profile(follow=[]): 
        "Helpful if you accidentally leave in production!" 
        def inner(func): 
            def nothing(*args, **kwargs): 
                return func(*args, **kwargs) 
            return nothing 
        return inner 
  
def get_number(): 
    for x in xrange(5000000): 
        yield x 
  
@do_profile(follow=[get_number]) 
def expensive_function(): 
    for x in get_number(): 
        i = x ^ x ^ x 
    return 'some result!' 
  
result = expensive_function() 
 
上のコードを実行すると、下のようなレポートが表示される: 
1 
2 
3 
4 
5 
6 
7 
8 
9 
10 
11 
12 
13 
14 
15 
16 
17 
18 
19 
20 
21 
22 
     
Timer unit: 1e-06 s 
  
File: test.py 
Function: get_number at line 43 
Total time: 4.44195 s 
  
Line #      Hits         Time  Per Hit   % Time  Line Contents 
============================================================== 
    43                                           def get_number(): 
    44   5000001      2223313      0.4     50.1      for x in xrange(5000000): 
    45   5000000      2218638      0.4     49.9          yield x 
  
File: test.py 
Function: expensive_function at line 47 
Total time: 16.828 s 
  
Line #      Hits         Time  Per Hit   % Time  Line Contents 
============================================================== 
    47                                           def expensive_function(): 
    48   5000001     14090530      2.8     83.7      for x in get_number(): 
    49   5000000      2737480      0.5     16.3          i = x ^ x ^ x 
    50         1            0      0.0      0.0      return 'some result!' 

ご覧のように、コードがどのように実行されているかを完全に把握できる非常に詳細なレポートがあります。組み込みのcProfilerを使わなくても、ループやインポートなどのコア言語機能に費やされた時間を計算し、異なる行に費やされた時間を教えてくれます。

これらの詳細により、関数内部を理解しやすくなります。サードパーティのライブラリで作業している場合は、それをインポートしてデコレータを追加するだけで解析できます。

ヒント:テスト関数のみを装飾し、次の引数として問題関数を取ります。

  • Line Profilerの 長所:非常に分かりやすく、詳細なレポート。サードパーティライブラリの関数をトレースする機能。
  • ライン・プロファイラの 短所:実際のランタイムよりはるかにコードが遅くなるので、ベンチマークには使わないでください。余計な要求です。

まとめとベストプラクティス

テストケースの基本的なチェックを行うにはよりシンプルなツールを使い、関数の内部により深く入り込むには、速度は遅いですがより詳細なline_profilerを使うべきです。

関数内の循環呼び出しや間違ったデータ構造が、時間の90%を消費していることに気づくかもしれません。このような場合に最適なツールがあります。

それでも時間がかかりすぎる場合は、代わりに「属性アクセスの比較」や「バランスチェックの調整」などの裏技を使ってください。また、以下を使うこともできます:

1.遅さを我慢するか、キャッシュするか

2.全体的な実現の再考

3.最適化されたデータ構造の使用の増加

4.C言語による拡張

コードの最適化は後ろめたいことです!正しい方法でPythonコードを高速化するのは楽しいですが、ロジック自体を壊さないように注意してください。読みやすいコードは実行速度よりも重要です。実際には、最適化する前にキャッシュする方が良いでしょう。

Read next

ファーウェイとインテル、ストレージ戦略的協力協定を締結

情報通信ソリューションのプロバイダーであるファーウェイと、コンピューティング・イノベーションの世界的リーダーであるインテル コーポレーションは、IDF2014において、両者のリソースの優位性を統合し、技術提携、製品研究開発、マーケティングなど様々な側面からビッグデータ技術の発展を加速させるべく、さらなる協力関係の深化を目指し、ストレージに関する戦略的協力覚書を締結したことを発表しました。

Nov 18, 2015 · 3 min read