関数でPythonコードがより速く実行されるのはなぜですか?


835
def main():
    for i in xrange(10**8):
        pass
main()

このPythonのコードは、以下で実行されます(注:タイミングは、LinuxのBASHのtime関数で実行されます)。

real    0m1.841s
user    0m1.828s
sys     0m0.012s

ただし、forループが関数内に配置されていない場合、

for i in xrange(10**8):
    pass

その後、より長い時間実行されます。

real    0m4.543s
user    0m4.524s
sys     0m0.012s

どうしてこれなの?


16
実際にどのようにタイミングをとったのですか?
Andrew Jaffe

53
それが本当かどうかはわかりませんが、スコープが原因であると思います。関数の場合、新しいスコープが作成されます(つまり、変数名が値にバインドされたハッシュの一種)。関数がなければ、変数はグローバルスコープにあり、多くのものを見つけることができるため、ループが遅くなります。
シャロン

4
@シャロンそれはそうではないようです。実行時間に目に見える影響を与えることなく、スコープに200kのダミー変数を定義しました。
Deestan 2012年


53
@Scharron、あなたは半分正解です。スコープについてですが、ローカルでより高速な理由は、ローカルスコープが実際には辞書ではなく配列として実装されているためです(そのサイズはコンパイル時に既知であるため)。
カトリエル

回答:


532

グローバル変数よりもローカル変数を格納する方が速い理由を尋ねるかもしれません。これはCPython実装の詳細です。

CPythonはインタープリターが実行するバイトコードにコンパイルされることを覚えておいてください。関数がコンパイルされると、ローカル変数は(a ではなくdict)固定サイズの配列に格納され、変数名がインデックスに割り当てられます。関数にローカル変数を動的に追加できないため、これが可能です。次に、ローカル変数の取得は、文字通りリストへのポインタールックアップであり、refcountの増加は取るに足らないPyObjectものです。

これを、ハッシュなどを含むLOAD_GLOBAL真のdict検索であるグローバルルックアップ()と比較してください。ちなみに、これがglobal iグローバルにしたいかどうかを指定する必要があるのはこのためです。スコープ内の変数に代入すると、コンパイラーはSTORE_FAST、アクセスしないように指示しない限り、そのアクセスに対してsを発行します。

ちなみに、グローバルルックアップはまだかなり最適化されています。属性ルックアップfoo.bar本当に遅いものです!

以下は、局所変数効率の小さなです。


6
これはPyPyにも当てはまります。現在のバージョン(この記事の執筆時点では1.8)までです。OPからのテストコードの実行は、グローバルスコープでは、関数の内部に比べて約4倍遅くなります。
GDorn

4
@Walkerneo逆に言っていない限り、そうではありません。katrielalexとecatmurが言っていることによると、グローバル変数ルックアップは、ストレージの方法により、ローカル変数ルックアップよりも遅くなります。
Jeremy Pridemore

2
@Walkerneoここで行われる主な会話は、関数内のローカル変数ルックアップとモジュールレベルで定義されているグローバル変数ルックアップの比較です。この回答に対する元のコメントの返信に気付いた場合、「グローバル変数のルックアップがローカル変数のプロパティのルックアップよりも速いとは思っていなかったでしょう」そしてそうではありません。katrielalexは、ローカル変数のルックアップはグローバルルックアップよりも高速ですが、グローバルルックアップもかなり最適化されており、属性ルックアップ(異なる)より高速であると述べています。このコメントにはこれ以上のスペースがありません。
ジェレミープライドモア2012年

3
@Walkerneo foo.barはローカルアクセスではありません。オブジェクトの属性です。(フォーマットの不足を許してください)def foo_func: x = 5x関数に対してローカルです。アクセスxはローカルです。foo = SomeClass()foo.bar属性アクセスです。val = 5グローバルはグローバルです。速度については、ここで読んだ内容に応じて、ローカル>グローバル>属性です。したがってx、in へのアクセスfoo_funcが最も速く、が続きval、が続きfoo.barます。foo.attrこのconvoのコンテキストでは、ローカルルックアップは関数に属する変数のルックアップであることから、ローカルルックアップではありません。
Jeremy Pridemore

3
@thedoctarがglobals()関数を見てください。それ以上の情報が必要な場合は、Pythonのソースコードを調べ始める必要があるかもしれません。CPythonは、Pythonの通常の実装の名前にすぎません。そのため、おそらくすでに使用しています。
カトリエル

661

関数内では、バイトコードは次のとおりです。

  2           0 SETUP_LOOP              20 (to 23)
              3 LOAD_GLOBAL              0 (xrange)
              6 LOAD_CONST               3 (100000000)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_FAST               0 (i)

  3          19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK           
        >>   23 LOAD_CONST               0 (None)
             26 RETURN_VALUE        

トップレベルでは、バイトコードは次のとおりです。

  1           0 SETUP_LOOP              20 (to 23)
              3 LOAD_NAME                0 (xrange)
              6 LOAD_CONST               3 (100000000)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_NAME               1 (i)

  2          19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK           
        >>   23 LOAD_CONST               2 (None)
             26 RETURN_VALUE        

違いはそれがあるSTORE_FAST速い(!)よりもですSTORE_NAME。これは、関数でiはローカルですが、トップレベルではグローバルであるためです。

バイトコードを調べるには、disモジュールを使用します。関数を直接逆アセンブルすることはできましたが、トップレベルのコードを逆アセンブルするには、compile組み込みを使用する必要がありました。


171
実験により確認済み。挿入global imain機能することは実行している時間は同等になります。
Deestan 2012年

44
これは、質問に答えることなく、質問に答える:)辞書が要求されるまで、ローカル関数変数の場合には、CPythonのは、実際には(Cコードから変更可能である)タプルでこれらを格納する(例えば介してlocals()、又はinspect.getframe()等)。定数の整数で配列要素を検索する方が、dictを検索するよりもはるかに高速です。
dmw

3
これはC / C ++でも同じで、グローバル変数を使用すると大幅な
速度

3
これは私がバイトコードについて最初に見たものです。
Zack

4
@gkimsey同意する。ただ、2つのことを共有したいと思った私は)この動作は、他のプログラミング言語で注目されるII)原因物質は、より多くの建築側ではなく、真の意味での言語自体である
codejammer

41

ローカル/グローバル変数の格納時間は別として、オペコード予測は関数をより高速にします。

他の答えが説明するように、関数はSTORE_FASTループでオペコードを使用します。関数のループのバイトコードは次のとおりです。

    >>   13 FOR_ITER                 6 (to 22)   # get next value from iterator
         16 STORE_FAST               0 (x)       # set local variable
         19 JUMP_ABSOLUTE           13           # back to FOR_ITER

通常、プログラムが実行されると、Pythonは各オペコードを1つずつ実行し、スタックを追跡し、各オペコードの実行後にスタックフレームで他のチェックを実行します。オペコード予測は、特定のケースでPythonが次のオペコードに直接ジャンプできるため、このオーバーヘッドの一部を回避できることを意味します。

この場合、PythonがFOR_ITER(ループの先頭)を見るたびにSTORE_FAST、実行する必要がある次のオペコードを「予測」します。次に、Pythonは次のオペコードを調べ、予測が正しければ、そのままにジャンプしSTORE_FASTます。これには、2つのオペコードを1つのオペコードにまとめる効果があります。

一方、STORE_NAMEオペコードはループ内でグローバルレベルで使用されます。Pythonは、このオペコードを検出しても、*同様の予測を行いません*。代わりに、評価ループの先頭に戻る必要があります。これは、ループが実行される速度に明らかに影響します。

この最適化に関する技術的な詳細を説明するために、ceval.cファイル(Pythonの仮想マシンの「エンジン」)からの引用を次に示します。

一部のオペコードはペアになる傾向があるため、最初のコードが実行されたときに2番目のコードを予測できます。たとえば、 GET_ITER後にが続くことがよくありFOR_ITERます。またFOR_ITER、多くの場合、STORE_FASTまたはが続きUNPACK_SEQUENCEます。

予測の検証には、定数に対するレジスタ変数の高速テストが1回必要です。ペアリングが適切であった場合、プロセッサ自体の内部分岐予測は成功する可能性が高く、オーバーヘッドがほぼゼロの次のオペコードに移行します。予測が成功すると、2つの予測不可能な分岐(HAS_ARGテストとスイッチケース)を含むevalループを通過する手間が省けます。プロセッサの内部分岐予測と組み合わせると、成功PREDICTすると、2つのオペコードが本体を組み合わせた1つの新しいオペコードであるかのように実行されます。

FOR_ITERオペコードのソースコードで、予測STORE_FASTが行われる場所を正確に確認できます。

case FOR_ITER:                         // the FOR_ITER opcode case
    v = TOP();
    x = (*v->ob_type->tp_iternext)(v); // x is the next value from iterator
    if (x != NULL) {                     
        PUSH(x);                       // put x on top of the stack
        PREDICT(STORE_FAST);           // predict STORE_FAST will follow - success!
        PREDICT(UNPACK_SEQUENCE);      // this and everything below is skipped
        continue;
    }
    // error-checking and more code for when the iterator ends normally                                     

PREDICT関数は次のように拡張されます。if (*next_instr == op) goto PRED_##opつまり、予測されるオペコードの先頭にジャンプします。この場合、ここにジャンプします。

PREDICTED_WITH_ARG(STORE_FAST);
case STORE_FAST:
    v = POP();                     // pop x back off the stack
    SETLOCAL(oparg, v);            // set it as the new local variable
    goto fast_next_opcode;

ローカル変数が設定され、次のオペコードが実行されます。Pythonは最後まで到達するまで反復可能オブジェクトを継続し、毎回成功する予測を行います。

Pythonのwikiページには、 CPythonのの仮想マシンがどのように機能するかについての詳細な情報を持っています。


マイナーアップデート:CPython 3.6の時点で、予測による節約が少し減少しました。予測できない2つのブランチの代わりに、1つしかありません。この変更は、バイトコードからワードコードへの切り替えによるものです。現在、すべての「ワードコード」には引数があります。命令が論理的に引数をとらない場合は、ゼロに設定されます。したがって、HAS_ARGテストは発生せず(コンパイル時と実行時の両方で低レベルのトレースが有効になっている場合を除き、通常のビルドでは実行されません)、予測できないジャンプが1つだけ残ります。
ShadowRanger

新しい(Python 3.1以降、3.2でデフォルトで有効になっている)計算されたgotos動作のため、CPythonのほとんどのビルドではその予測できないジャンプは発生しません。使用すると、PREDICTマクロは完全に無効になります。代わりに、ほとんどの場合、DISPATCH直接分岐するa で終わります。ただし、分岐予測CPUでは、PREDICT分岐(および予測)がオペコードごとに行われるため、分岐予測が成功する確率が高くなるため、効果はと同様です。
ShadowRanger
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.