PHP 7でマジック関数を呼び出すためのスプリングボード





この記事では、PHP 7の仮想マシン(Zend仮想マシン)での最適化について詳しく見ていきます。 最初に、関数呼び出しの踏み台の理論に触れてから、それらがPHP 7でどのように機能するかを学習します。すべてを完全に理解したい場合は、Zend仮想マシンについてよく理解することをお勧めします。 PHP 5でVM どのように機能するかを読むには、PHP 7 VMについて説明します。再設計されていますが、PHP 7とほぼ同じように動作します。したがって、PHP 5 VMを理解している場合は、 PHP VM 7は問題ありません。



それはすべて再帰です



スキージャンプを聞いたことがない場合は、おそらくHaskellやScalaなどの言語に触れたことがないでしょう。 関数呼び出しランプは、高度なプログラミングコースで一般的に教えられるトリックです。 踏み台の役割は、関数呼び出しの再帰を防ぐことです。 これは理論的根拠です。 再帰とは何かわからない場合は、まず見てください。 複雑なことは何もありません。



アプリケーションにスプリングボードメカニズムを実装するには多くの方法があります。 簡単な例から始めましょう:



function factorial($n) { if ($n == 1) { return $n; } return $n * factorial($n-1); }
      
      





再帰を理解する最も簡単な方法は、よく知られている階乗関数です。 彼女にはほとんど指示がなく、何よりも自分自身を呼び出します。



ご存知のように、すべての関数呼び出しで多くのことが起こります。 下位レベルでは、コンパイラーは関数呼び出し規約を使用して呼び出しの準備をします。 実際、コンパイラーは最初に引数と戻りアドレスをスタックに渡し、次にCALLオペコードを生成します。これにより、プロセッサーが関数本体の最初の命令に転送されます。 完了すると、RETURNオペコードが使用され(関数本体にない場合はコンパイラによって生成されます)、プロセッサに引数スタックを取り除き(スタックポインタをリセット)、リターンアドレスに戻るように指示します。



このモデルの問題は、スタックがメモリの一部であり、有限でサイズが小さいことです。 Linuxでは、通常8 MBがスタックに割り当てられます( ulimit -a )。 再帰レベルの各レコードはスタック上に新しいフレームを作成するため、再帰関数はスタックを非常に積極的に使用します。 あまりにも夢中になったら、スタック全体を埋めることができます。 この場合、カーネルは通常、SIGBUSシグナルをプロセッサに発行し、以前にクラッシュしない場合(たとえばalloca()



を使用する場合alloca()



スタックを終了します。



スタック上の場所はめったに終了しませんが(プログラムのバグを除く)、再帰関数に加えて、スタックを作成し、(関数が戻ったときに)呼び出しフレームを破壊すると、害を及ぼす可能性があります。 これにより、プロセッササイクルの一部がなくなり( mov



pop



push



call



などのスタック指向の命令の場合)、常にメインメモリへのアクセスが必要になります。 そして-それは遅いです。 子を呼び出さずにプログラムが単一の関数で動作する場合、より速く動作します:プロセッサは、スタックを無限に作成および削除する必要がなく、プログラムが直接使用しないメモリのブロックを移動する必要はありません。それらは単にアーキテクチャの一部です。 現在、プロセッサは通常、レジスタを使用してスタック引数を保持したり、アドレスを返したりします(LinuxのLP64など)が、最も深いレベルであっても再帰を回避します。



再帰防止



関数を呼び出すときに再帰を防ぐ方法はいくつかあります。 PHPを使用して、簡単な方法を探ります。 springboard関数を使用したより伝統的な方法を研究し、次にPHPソースコードを例として使用して、PHP 7のコアに追加されたこのメカニズムの操作を検討します。



テールコール関数とループ



再帰は一種のサイクルです。「XXXでは、私は自分自身に挑戦します。」 そのため、再帰関数は、それ自体を呼び出さずにループ(場合によっては複数)を使用して書き換えることができます。 ただし、これは簡単なタスクではなく、関数自体に依存していることを覚えておいてください。それは何回、どのように呼び出すかです。



幸いなことに、階乗関数は簡単に「脱線」できます。 これを行うには、末尾呼び出し変換と呼ばれるメソッドを使用します。 階乗関数を展開するには、それを再帰的な末尾呼び出し関数に変換し、特定のルールを適用します。 最初に変換を行いましょう:



 function tail_factorial($n, $acc = 1) { if ($n == 1) { return $acc; } return tail_factorial($n-1, $n * $acc); }
      
      





ここでは、いわゆるバッテリーを使用しました。 最後に、末尾呼び出し関数を取得する必要があります。 思い出させてください。これは関数の名前であり、それ自体を返すことになると、他の操作を実行せずにそれを行います。 つまり、return式は、追加の操作なしで再帰関数のみを転送します。 たとえば、単一の命令ストリームを使用した再入力(単一命令再入可能)。 したがって、コンパイラは最後の呼び出しを最適化します。関数はそれ自体を返すだけなので、新しいフレームを作成するのではなく、スタックの現在のフレームを再利用することで、スタックの作成が簡素化されます。 また、本体が単なるループであるこの末尾呼び出し関数を変換することもできます。 変更された引数で関数をコールバックする代わりに、関数の先頭に再度ジャンプする必要があります(再帰呼び出しのように)が、引数を変更して、次のループが引数の正しい値で実行されるようにします(再帰関数が行うように)。 取得するもの:



 function unrolled_factorial($n) { $acc = 1; while ($n > 1) { $acc *= $n--; } return $acc; }
      
      





この関数は、元のfactorial()



と同じことを行いますが、それ自体を呼び出しません。 実行時に、これは再帰的な代替手段よりもはるかに生産的です。



また、 goto



ブランチを使用することもできます。



 function goto_factorial($n) { $acc = 1; f: if ($n == 1) { return $acc; } $acc *= $n--; goto f; }
      
      





また、再帰はありません。



factorial()



を膨大な数で実行してみてください:スタックを使い果たし、エンジンのメモリ制限に達します(仮想マシンのスタックフレームがヒープに配置されるため)。 制限( memory_limit )を無効にすると、PHPもZend仮想マシンも無限再帰に対する保護を持たないため、PHPはクラッシュします。 その結果、プロセスは崩壊します。 同じ引数unrolled_factorial()



またはgoto_factorial()



実行してみてください。 システムは落ちません。 すぐには実行されないかもしれませんが、クラッシュすることはなく、スタック上の場所(PHPヒープに割り当てられている)は終了しません。 ただし、実行速度は再帰関数の場合よりもはるかに高速になります。



テールコールコントロールスプリングボード機能



関数の再帰は簡単ではないことがあります。 階乗は単純ですが、他のいくつかはもっと複雑です。 たとえば、さまざまな場所、さまざまな条件などで自分自身を呼び出す関数( bsearch()



単純な実装など)。



このような場合、再帰を抑制するために踏み台が必要になる場合があります。 基本的な再帰関数を(再帰のように)書き換える必要がありますが、今回はそれ自体を呼び出すことができます。 これらの呼び出しは、直接ではなく、スプリングボードを使用して実行することで単純にマスクします。 したがって、再帰は制御フロー(スプリングボード)の存在下で展開され、関数の各呼び出しを制御します。 複雑な関数を再帰する方法について困惑する必要はもうありません。単にラップして、スプリングボードと呼ばれる制御コードを介して実行するだけです。



この概念をPHPで使用する例を見てみましょう。 発想は、関数を変換して、呼び出し元のコードが再帰を開始するタイミングと終了するタイミングを決定できるようにすることです。 これを再帰呼び出し自体に適用すると、スプリングボードが呼び出され、スタックを制御します。 彼が結果を返すと、スプリングボードはこれに気づき、停止します。



このように:



 function trampo_factorial($n, $acc = 1) { if ($n == 1) { return $acc; } return function() use ($n, $acc) { return trampo_factorial($n-1, $n * $acc); }; }
      
      





ここでは、関数はまだ自分自身を呼び出しています。 ただし、これは直接行われませんが、再帰呼び出しをクロージャーにラップします。 結局、再帰関数を直接ではなく、踏み台を通して実行したいのです。 クロージャが戻ったことを確認すると、関数を開始します。 クロージャでない場合、関数を返します。



 function trampoline(callable $c, ...$args) { while (is_callable($c)) { $c = $c(...$args); } return $c; }
      
      





できた この方法を使用します。



echo trampoline('trampo_factorial', 42);







踏み台は、再帰問題の通常の解決策です。 関数をリファクタリングして再帰呼び出しを除外できない場合は、スプリングボードを介して実行できる末尾呼び出し関数に変換します。 もちろん、ジャンプは末尾呼び出し関数でのみ機能します。



スプリングボードを使用すると、呼び出された関数は必要な回数だけ起動されますが、再帰的に呼び出すことはできません。 スプリングボードは呼び出されたものとして機能します。 任意の再帰関数に適用できるはるかに普遍的な方法で再帰問題を解決しました。



ここでは、アイデアの本質を説明するためだけにPHPを使用しました(これらの行を読んだときにPHPによく出くわすと思います)。 しかし、この言語でジャンプを作成することはお勧めしません。 PHPは高水準言語であり、そのような構造は日常の作業では必要ありません。 多くの場合、再帰関数は必要ありません。また、内部でis_callable()



を呼び出すループはそれほど軽量ではありません。



それでも、PHPエンジンをさらに深く掘り下げて、PHP仮想マシンのメインディスパッチループでのスタックの再帰を防ぐためにここでジャンプがどのように実装されるかを見てみましょう。



Zend仮想マシンでの再帰



ディスパッチサイクルが何であるかを忘れないでください。



あなたの記憶でこれをリフレッシュさせてください。 すべての仮想マシンは、いくつかの一般的なアイデアに基づいて構築されており、その中にはディスパッチサイクルがあります。 無限ループopline



、各反復で、仮想マシンの1つのhandler()



opline



)が実行されます( handler()



)。 この命令のフレームワーク内では多くのことが起こりますが、最後には常にループへのコマンドがあります。通常、これは次の反復(goto next)に進むコマンドです。 無限ループからの戻りコマンドまたはこの操作への移行コマンドもあります。



デフォルトでは、エンジン仮想マシンのディスパッチサイクルはexecute_ex()



関数に保存されます。 以下は、私のコンピューター用に最適化されたPHP 7の例です(IPおよびFPレジスターを使用):



 #define ZEND_VM_FP_GLOBAL_REG "%r14" #define ZEND_VM_IP_GLOBAL_REG "%r15" register zend_execute_data* volatile execute_data __asm__(ZEND_VM_FP_GLOBAL_REG); register const zend_op* volatile opline __asm__(ZEND_VM_IP_GLOBAL_REG); ZEND_API void execute_ex(zend_execute_data *ex) { const zend_op *orig_opline = opline; zend_execute_data *orig_execute_data = execute_data; execute_data = ex; opline = execute_data->opline; while (1) { opline->handler(); if (UNEXPECTED(!opline)) { execute_data = orig_execute_data; opline = orig_opline; return; } } zend_error_noreturn(E_CORE_ERROR, "Arrived at end of main loop which shouldn't happen"); }
      
      





while(1)



構造while(1)



注意してください。 再帰はどうですか? どうしたの?



すべてがシンプルです。 execute_ex()



関数の一部としてwhile(1)



を開始しました。 1つの命令( opline->handler()



)がexecute_ex()



どうなりますか? 再帰があります。 これは悪いです。 いつものように:はい、それがマルチレベルになる場合。



その場合、 execute_ex()



execute_ex()



呼び出しますか? ここでは、多くの重要な情報を見落とす可能性があるため、仮想マシンエンジンについて詳しく説明しません。 簡単にするために、これはexecute_ex()



を呼び出すPHP関数の呼び出しであると想定します。



PHP関数を呼び出すたびに、C言語レベルで新しいスタックフレームが作成され、ディスパッチサイクルの新しいバージョンが開始され、実行する新しい命令でexecute_ex()



新しい呼び出しが再入力されます。 このループが表示されると、PHP関数呼び出しが完了し、コード内でリターンプロシージャが実行されます。 その結果、スタック上の現在のフレームの現在のループは、前のフレームのリターンで終了します。 これは、ユーザー空間のPHP関数でのみ発生することに注意してください。 その理由は、ユーザー定義のPHP関数は、開始後にループで実行されるオペコードだからです。 ただし、Cで開発され、カーネルまたは拡張機能にある内部PHP関数は、オペコードを実行する必要はありません。 これらは純粋なCの命令であるため、異なるディスパッチサイクルと異なるフレームは作成されません。



__Call()使用方法



ここで、 __call()



使用方法を説明します。 これは、ユーザー空間からのPHP関数です。 すべてのユーザー定義関数と同様に、その実行によりexecute_ex()



新しい呼び出しが行われます。 しかし、実際には__call()



を複数回呼び出して、多くのフレームを作成できます。 クラスで定義された__call()



を使用して、オブジェクトのコンテキストで不明なメソッドが呼び出されるたびに。



PHP 7では、追加の__call()



マスタリング呼び出しを使用することにより、また__call()



の場合にexecute_ex()



再帰呼び出しを防ぐことにより、エンジンが最適化されました。



PHP 5.6 __call()











execute_ex()



は3つの呼び出しがあります。 これは、オブジェクトのコンテキストで不明なメソッドを呼び出すPHPスクリプトから取得され、別のオブジェクトのコンテキストで不明なメソッドを呼び出します(どちらの場合も、クラスには__call()



が含まれます)。 したがって、最初のexecute_ex()



はメインスクリプト(呼び出しスタックの6 execute_ex()



の実行であり、リストの一番上には他の2つのexecute_ex()



ます。



PHP 7で同じスクリプトを実行します。







違いは明らかです。スタックフレームははるかに薄く、 execute_ex()



呼び出しは1つだけです。つまり、 execute_ex()



call __call()



呼び出しを含むすべての命令を制御する1つのディスパッチサイクルです。



__call()呼び出しをスプリングボード呼び出しに変換します



PHP 5では、 execute_ex()



のコンテキストでexecute_ex()



を呼び出しました。 つまり、現在要求されている__call()



オペコードを実行する新しいディスパッチサイクルを準備しました。



たとえば、 fooBarDontExist()



などのメソッドを実行します。 いくつかの構造体をメモリに保存し、ユーザー空間から古典的な関数呼び出しを実行する必要があります。 このようなもの(簡略化):



 ZEND_API void zend_std_call_user_call(INTERNAL_FUNCTION_PARAMETERS) { zend_internal_function *func = (zend_internal_function *)EG(current_execute_data)->function_state.function; zval *method_name_ptr, *method_args_ptr; zval *method_result_ptr = NULL; zend_class_entry *ce = Z_OBJCE_P(this_ptr); ALLOC_ZVAL(method_args_ptr); INIT_PZVAL(method_args_ptr); array_init_size(method_args_ptr, ZEND_NUM_ARGS()); /* ... ... */ ALLOC_ZVAL(method_name_ptr); INIT_PZVAL(method_name_ptr); ZVAL_STRING(method_name_ptr, func->function_name, 0); /*    */ /*     :   execute_ex() */ zend_call_method_with_2_params(&this_ptr, ce, &ce->__call, ZEND_CALL_FUNC_NAME, &method_result_ptr, method_name_ptr, method_args_ptr); if (method_result_ptr) { RETVAL_ZVAL_FAST(method_result_ptr); zval_ptr_dtor(&method_result_ptr); } zval_ptr_dtor(&method_args_ptr); zval_ptr_dtor(&method_name_ptr); efree(func); }
      
      





この呼び出しを行うには、多くの作業が必要です。 そのため、「パフォーマンスを向上させるために__call()



を避けてみてください」という言葉をよく耳にします(その他の理由もあります)。 本当にそうです。



PHP 7について。スプリングボード理論を覚えていますか? ここではすべてがほぼ同じです。 execute_ex()



再帰呼び出しを避ける必要があります。 これを行うには、必要な引数を変更して、 execute_ex()



と同じコンテキストのままプロシージャを再度再帰し、先頭にリダイレクト(再分岐)します。 execute_ex()



もう一度見てみましょう:



 ZEND_API void execute_ex(zend_execute_data *ex) { const zend_op *orig_opline = opline; zend_execute_data *orig_execute_data = execute_data; execute_data = ex; opline = execute_data->opline; while (1) { opline->handler(); if (UNEXPECTED(!opline)) { execute_data = orig_execute_data; opline = orig_opline; return; } } zend_error_noreturn(E_CORE_ERROR, "Arrived at end of main loop which shouldn't happen"); }
      
      





したがって、再帰呼び出しを防ぐために、少なくともopline



およびexecute_data



変数を変更する必要があります(次のopcodeを含み、oplineは実行する「現在の」opcodeです)。 __call()



に会うとき:



  1. opline



    execute_data



    opline



    ます。
  2. 返金します。
  3. 現在のディスパッチサイクルに戻ります。
  4. 新しく変更された新しいオペコードに対して、引き続き実行します。
  5. その結果、元の位置に強制的に戻ります(したがって、 orig_opline



    orig_execute_data



    ます。仮想マシンマネージャーは、どこからでも分岐できるように、常にそれがどこから来たかを覚えておく必要があります)。


これは、新しいオペコードZEND_CALL_TRAMPOLINE



がPHP 7で行うこととまったく同じです。 __call()



呼び出しが行われるべき場所で使用されます。 簡易版を見てみましょう。



 #define ZEND_VM_ENTER() execute_data = (executor_globals.current_execute_data); opline = ((execute_data)->opline); return static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_CALL_TRAMPOLINE_SPEC_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { zend_array *args; zend_function *fbc = EX(func); zval *ret = EX(return_value); uint32_t call_info = EX_CALL_INFO() & (ZEND_CALL_NESTED | ZEND_CALL_TOP | ZEND_CALL_RELEASE_THIS); uint32_t num_args = EX_NUM_ARGS(); zend_execute_data *call; /* ... */ SAVE_OPLINE(); call = execute_data; execute_data = EG(current_execute_data) = EX(prev_execute_data); /* ... */ if (EXPECTED(fbc->type == ZEND_USER_FUNCTION)) { call->symbol_table = NULL; i_init_func_execute_data(call, &fbc->op_array, ret, (fbc->common.fn_flags & ZEND_ACC_STATIC) == 0); if (EXPECTED(zend_execute_ex == execute_ex)) { ZEND_VM_ENTER(); } /* ... */
      
      





変数execute_data



およびopline



、マクロZEND_VM_ENTER()



を使用して効果的に変更されることに気付くでしょう。 次のexecute_data



call



変数で準備され、それらのバインドはi_init_func_execute_data()



関数によって実行されます。 次に、 ZEND_VM_ENTER()



を使用して、ディスパッチサイクルの新しい反復が実行され、変数が次のサイクルに切り替えられ、「現在のサイクルの」「リターン」で入力する必要があります。



円は閉じられ、終わりました。



メインループに戻る方法 これは、ユーザー定義関数を終了するZEND_RETURN ZEND_RETURN



で行われます。



 #define LOAD_NEXT_OPLINE() opline = ((execute_data)->opline) + 1 #define ZEND_VM_LEAVE() return static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL zend_leave_helper_SPEC(ZEND_OPCODE_HANDLER_ARGS) { zend_execute_data *old_execute_data; uint32_t call_info = EX_CALL_INFO(); if (EXPECTED(ZEND_CALL_KIND_EX(call_info) == ZEND_CALL_NESTED_FUNCTION)) { zend_object *object; i_free_compiled_variables(execute_data); if (UNEXPECTED(EX(symbol_table) != NULL)) { zend_clean_and_cache_symbol_table(EX(symbol_table)); } zend_vm_stack_free_extra_args_ex(call_info, execute_data); old_execute_data = execute_data; execute_data = EG(current_execute_data) = EX(prev_execute_data); /* ... */ LOAD_NEXT_OPLINE(); ZEND_VM_LEAVE(); } /* ... */
      
      





ご覧のとおり、呼び出しからユーザー定義関数を返す場合、 ZEND_RETURN



を使用しZEND_RETURN



。これは、キュー内の次を置き換えて、前のprev_execute_data



呼び出しの前の命令で実行します。 次に、oplineをロードし、メインディスパッチサイクルに戻ります。



おわりに



アンロール関数呼び出しの背後にある理論を調べました。 再帰呼び出しは修正できますが、非常に難しい場合があります。 普遍的な解決策は、スプリングボードの開発です。それは、再帰関数の各ステージの起動を制御するシステムであり、それ自体を呼び出すことを許可せず、したがって、制御不能なスタックフレームの生成を防ぎます。 「スプリングボード」コードはディスパッチャにあり、それを制御して再帰を防ぎます。



また、PHPでの一般的な実装を調べ、PHP 7の一部である新しいZend 3エンジンでのスプリングボードの実装を調べました__call()



call __call()



を呼び出すことを恐れないCレベル)、これはPHP 7エンジンの改善点の1つです。



All Articles