原理原則で理解するPHP8のコンパイルと実行処理の仕組み(JITもあるよ)

46 min read読了の目安(約42100字

はじめに

PHPはプログラミング言語です。1995年に世に出てからこれまで多くの開発者に利用され、様々なソフトウェアを生み出してきました。

学習コストが低く手軽に実行できるトレードオフとして、バグを生み出しやすいコードになりやすかったりパフォーマンス上の懸念から大規模なアプリケーション開発に使うものではないという認識は結構あるのではないかと思っています。

しかしながら2021年現在の最新のメジャーバージョンであるPHP8では、機能面やパフォーマンス面においても大規模開発に対して十分使えるものになっていると個人的には思ってます。

今回は今PHPを使っている開発者やこれからPHPを使う予定の方、あるいは「PHPなんて興味ないけどどういう仕組みなのかは見てやろうじゃないか」と思っているような方にも、PHPの内部ってこういう感じで動いているんだっていうイメージを掴んで頂けたらと思って記事にしてみました。

PHPの内部動作については、普段使っている開発者でもよく知らないって方が多いんじゃないかと思います。※自分もその一人だったので今回の行動に繋がっています。

なお、今回は起動から終了までのイメージを掴むことを主眼においているので、細かいデータ構造や処理については省略し、個人的に気になったところを必要に応じて触れるという形式をとっておりますのでご了承ください。

基本的にはC言語で書かれたPHPのソースコードから僕が個人的に仮説を立てながら追い、時には実行しながら判断した上での解説になるので、間違っているところもあるかもしれません。その際はコメント等で指摘頂けますと幸いです🙇‍♂️

また、今回ソースコードを読み進めるにあたって非常に参考になった資料を記事末尾に記載させて頂きます。著者の方々にはこの場を借りて感謝申し上げます。

検証環境

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.10
Release:        20.10
Codename:       groovy 
$ lshw -class processor
  *-cpu                     
       product: Intel(R) Core(TM) i7-8559U CPU @ 2.70GHz
       vendor: Intel Corp.
       physical id: 2
       bus info: cpu@0
       width: 64 bits
       capabilities: fpu fpu_exception wp vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx rdtscp x86-64 constant_tsc rep_good nopl xtopology nonstop_tsc cpuid tsc_known_freq pni pclmulqdq ssse3 cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx rdrand hypervisor lahf_lm abm 3dnowprefetch invpcid_single pti fsgsbase avx2 invpcid rdseed clflushopt md_clear flush_l1d

PHPのバージョン

PHP8.0.1 ※中途半端なコミット状況のものですみません

検証SAPI

PHPを外部から利用するには、SAPI(Server API)とよばれるインターフェースを用います。
いくつか提供されているSAPIの中で今回はCLI SAPIを利用します。

PHPをWebアプリケーションとして利用する場合、近年はphp-fpmを利用している方が多いと思いますがそれもSAPIの一つです。

他にどんなものがあるか興味がある方はsapiディレクトリの中を覗いてみると良いと思います。

なお、解説に関しては極力CLI SAPIに依存せずSAPIそのものについて言及するようにします。

検証スクリプトファイル

今回実行の対象となるファイルを紹介しておきましょう。色々試しているとinclude式を多用したものがシンプルで、OPcacheの例としてもわかりやすかったのでそれにしようとも思いましたが、現実からあまりにも逸れている気がしたので下記にしました。

exe.php
<?php

require_once 'func.php';

hoge();
hoge();
hoge();
func.php
<?php

function hoge () {
    echo 'hoge';
    echo 'hoge';
    echo 'hoge';
    echo 'hoge';
    echo 'hoge';
}

exe.phpを実行するとhoge関数が呼び出され、hogeという文字列が15回出力されます。

これが現実にあるプログラムかというと、決してそうではないですが関数が定義されている外部ファイルを読み込んで、その関数を複数回呼び出すという構造自体は結構あると思います。

CLIを利用するので、実行コマンド例としては下記です。

$ php exe.php
hogehogehogehogehogehogehogehogehogehogehogehogehogehogehoge

この結果に至るまでの処理を深堀りしていこうという試みです。

解説する順番

今回は下記の3つの状態に関して上から順番に解説を行います。

幸いPHP内部の処理はモジュール化されているので、基本的には一つ前の動作の一部が切り替わるという文脈で説明可能です。

  1. OPcache無効な状態
  2. OPcache有効な状態
  3. OPcache有効かつJITが有効な状態
    ※JITについてはOPCacheの一部として実装されています

OPcache無効な状態

最初はOPcacheが無効な状態で解説をはじめます。OPcacheはコンパイル済みのバイトコードを共有メモリやファイルに保存することにより実行速度を向上させる機能です。

実稼働環境では有効化必須の機能ですが、ボトムアップ的にPHPの処理を知る為には無効にしておいた方が良いので無効にしておきます。

自前でビルドした場合、設定ファイルが用意されないのでデフォルトで無効ですが、php.ini等の設定ファイルに記述があるようであれば無効化しておきます。

なお、検証にあたってはソースコードからビルドした方が都合が良いので、もし個人的に検証したい方がいらっしゃいましたら自前ビルドを強くオススメします。

;zend_extension=opcache 
$ php -v
PHP 8.1.0-dev (cli) (built: Jan 31 2021 16:57:15) ( NTS DEBUG )
Copyright (c) The PHP Group
Zend Engine v4.1.0-dev, Copyright (c) Zend Technologies
# with Zend OPcache v8.1.0-dev, Copyright (c), by Zend Technologies のような表示がでなければOK

メインルーチン

CLIのメインルーチンはsapi/cli/php_cli.cにあります。ここから追いかけていくことにしましょう。

sapi/cli/php_cli.c
int main(int argc, char *argv[])
#endif
{
#if defined(PHP_WIN32)
# ifdef PHP_CLI_WIN32_NO_CONSOLE
	int argc = __argc;
	char **argv = __argv;
# endif
	int num_args;
	wchar_t **argv_wide;
	char **argv_save = argv;
	BOOL using_wide_argv = 0;
#endif
# 省略

冒頭の部分で&cli_sapi_modulesapi_module_struct型の変数に代入しています。

sapi_module_structはSAPIが利用する共通のデータ構造で、SAPI内部での処理を抽象化しています。

sapi/cli/php_cli.c
sapi_module_struct *sapi_module = &cli_sapi_module;
# 省略
static sapi_module_struct cli_sapi_module = {
	"cli",							/* name */
	"Command Line Interface",    	/* pretty name */

	php_cli_startup,				/* startup */
	php_module_shutdown_wrapper,	/* shutdown */

	NULL,							/* activate */
	sapi_cli_deactivate,			/* deactivate */

	sapi_cli_ub_write,		    	/* unbuffered write */
	sapi_cli_flush,				    /* flush */
	NULL,							/* get uid */
	NULL,							/* getenv */

	php_error,						/* error handler */

	sapi_cli_header_handler,		/* header handler */
	sapi_cli_send_headers,			/* send headers handler */
	sapi_cli_send_header,			/* send header handler */

	NULL,				            /* read POST data */
	sapi_cli_read_cookies,          /* read Cookies */

	sapi_cli_register_variables,	/* register server variables */
	sapi_cli_log_message,			/* Log message */
	NULL,							/* Get request time */
	NULL,							/* Child terminate */

	STANDARD_SAPI_MODULE_PROPERTIES
};

たとえば、下記のub_writeメンバはSAPIにおいて出力を担う関数ポインタを保持していますがecho等の処理でクライアントに結果を出力する役割を担っています。

CLIであれば単純にディスプレイに出力する処理になりますが、WEBサーバではHTTPレスポンスヘッダー等のメタ情報の追加が行われるといった具合です。

main/SAPI.h
struct _sapi_module_struct {
        char *name;
        char *pretty_name;

        int (*startup)(struct _sapi_module_struct *sapi_module);
        int (*shutdown)(struct _sapi_module_struct *sapi_module);

        int (*activate)(void);
        int (*deactivate)(void);

        size_t (*ub_write)(const char *str, size_t str_length);
        void (*flush)(void *server_context);
        zend_stat_t *(*get_stat)(void);
        char *(*getenv)(const char *name, size_t name_len);

        void (*sapi_error)(int type, const char *error_msg, ...) ZEND_ATTRIBUTE_FORMAT(printf, 2, 3);

        int (*header_handler)(sapi_header_struct *sapi_header, sapi_header_op_enum op, sapi_headers_struct *sapi_headers);
        int (*send_headers)(sapi_headers_struct *sapi_headers);
        void (*send_header)(sapi_header_struct *sapi_header, void *server_context);

        size_t (*read_post)(char *buffer, size_t count_bytes);
        char *(*read_cookies)(void);

        void (*register_server_variables)(zval *track_vars_array);
        void (*log_message)(const char *message, int syslog_type_int);
        double (*get_request_time)(void);
        void (*terminate_process)(void);

        char *php_ini_path_override;

        void (*default_post_reader)(void);
        void (*treat_data)(int arg, char *str, zval *destArray);
        char *executable_location;

        int php_ini_ignore;
        int php_ini_ignore_cwd; /* don't look for php.ini in the current directory */

				int (*get_fd)(int *fd);

        int (*force_http_10)(void);

        int (*get_target_uid)(uid_t *);
        int (*get_target_gid)(gid_t *);

        unsigned int (*input_filter)(int arg, const char *var, char **val, size_t val_len, size_t *new_val_len);

        void (*ini_defaults)(HashTable *configuration_hash);
        int phpinfo_as_text;

        char *ini_entries;
        const zend_function_entry *additional_functions;
        unsigned int (*input_filter_init)(void);
};

以後main関数内ではこの変数(sapi_module)を用いて必要な処理を行っていきます。

初期化処理

ここから各種初期化処理を行っていきます。sapi_module->startup(sapi_module)の処理によってSAPI共通および、CLI独自の初期化処理を行っていきます。例えば以下のようなものがあります。

  • php.ini等の設定ファイルのパースおよび結果のPHP内部データ構造への設定処理
  • 各種内部ユーティリティ関数(zend_write,zend_fopen等)の登録処理
  • オペコードに対応するハンドラの初期化処理
  • 拡張モジュールのロードと初期化処理
  • zend_compile_fileの設定
sapi/cli/php_cli.c
/* startup after we get the above ini override se we get things right */
	if (sapi_module->startup(sapi_module) == FAILURE) {
		/* there is no way to see if we must call zend_ini_deactivate()
		 * since we cannot check if EG(ini_directives) has been initialised
		 * because the executor's constructor does not set initialize it.
		 * Apart from that there seems no need for zend_ini_deactivate() yet.
		 * So we goto out_err.*/
		exit_status = 1;
		goto out;
	}
	module_started = 1;

この中で特に重要なのものとして、zend_compile_fileという関数ポインタの設定をとりあげてみたいと思います。

共通処理の一つであるzend_startup関数内で、関数ポインタとして宣言されているzend_compile_fileに対してcompile_file関数のアドレスを代入しています。zend_execute_exも同様です。

zend_compile_file = compile_file;
zend_execute_ex = execute_ex;

zend_compile_fileは字句解析処理、構文解析処理(抽象構文木の生成含む)、オペコードに対応するハンドラの登録等、オペコードの実行直前までを担う(一連の処理がzend_compile_file内で行われている為、広義の意味でコンパイルと呼ぶことにします)関数なのですがこれに対して別の関数のアドレスを代入しているのは何故なのでしょうか?

先に答えを言ってしまうと、コンパイル処理を抽象化する為です。後にOPcacheを有効な状態の例を見ていきますがその際はzend_compile_fileに別の関数のアドレスが代入されることになります。

この話はまた後に出てくるので頭の隅に置いておいてください。

さて、必要な初期化処理が終わった後はmain関数内に戻り、do_cliという関数が呼ばれます。

sapi/cli/php_cli.c
zend_first_try {
#ifndef PHP_CLI_WIN32_NO_CONSOLE
		if (sapi_module == &cli_sapi_module) {
#endif
			exit_status = do_cli(argc, argv);
#ifndef PHP_CLI_WIN32_NO_CONSOLE
		} else {
			exit_status = do_cli_server(argc, argv);
		}
#endif
	} zend_end_try();

Zend Engineへ

この中でphp_execute_script関数が呼ばれます。これはCLIだけでなく他のSAPIでも実行される共通処理の関数となっています。
ここまでは言わば、PHPの実行エンジンであるZend EngineがPHPスクリプトを実行する為の舞台を整えたようなものです。

舞台が整ったので、Zend Engineに対して僕達が実行して欲しいリクエスト(プログラムの実行)をお願いできるようになった感じです。

sapi/cli/php_cli.c
switch (behavior) {
		case PHP_MODE_STANDARD:
			if (strcmp(file_handle.filename, "Standard input code")) {
				cli_register_file_handles(/* no_close */ PHP_DEBUG || num_repeats > 1);
			}

			if (interactive && cli_shell_callbacks.cli_shell_run) {
				EG(exit_status) = cli_shell_callbacks.cli_shell_run();
			} else {
				php_execute_script(&file_handle);
			}
			break;

php_execute_script関数内部ではZend Engineの入り口ともいえるzend_execute_scripts関数が呼ばれるので、実行スクリプトのファイルハンドルを渡し、zend_compile_file関数での処理に利用します。

Zend/zend.c
ZEND_API zend_result zend_execute_scripts(int type, zval *retval, int file_count, ...) /* {{{ */
{
	va_list files;
	int i;
	zend_file_handle *file_handle;
	zend_op_array *op_array;
	zend_result ret = SUCCESS;

	va_start(files, file_count);
	for (i = 0; i < file_count; i++) {
		file_handle = va_arg(files, zend_file_handle *);
		if (!file_handle) {
			continue;
		}

		if (ret == FAILURE) {
			/* If a failure occurred in one of the earlier files,
			 * only destroy the following file handles. */
			zend_file_handle_dtor(file_handle);
			continue;
		}

		op_array = zend_compile_file(file_handle, type);

先程みたように、今回zend_compile_fileの実体はcompile_file関数となっており、compile_file関数はZend/zend_language_scanner.lにて定義されています。

Zend/zend_language_scanner.l

ZEND_API zend_op_array *compile_file(zend_file_handle *file_handle, int type)
{
	zend_lex_state original_lex_state;
	zend_op_array *op_array = NULL;
	zend_save_lexical_state(&original_lex_state);

	if (open_file_for_scanning(file_handle)==FAILURE) {
		if (!EG(exception)) {
			if (type==ZEND_REQUIRE) {
				zend_message_dispatcher(ZMSG_FAILED_REQUIRE_FOPEN, file_handle->filename);
			} else {
				zend_message_dispatcher(ZMSG_FAILED_INCLUDE_FOPEN, file_handle->filename);
			}
		}
	} else {
		op_array = zend_compile(ZEND_USER_FUNCTION);
	}

	zend_restore_lexical_state(&original_lex_state);
	return op_array;
}

字句解析、構文解析

ここで字句解析、構文解析のフェーズに入ってくるので少し補足します。

PHPでは字句解析ルーチン生成にre2c、構文解析ルーチン生成にBisonを利用しています。

それぞれPHP本体の処理とは離れるので内部の詳細について今回は言及しませんが、下記のポイントを抑えておくと良いかもしれません。

  • 構文解析ルーチンと字句解析ルーチンのトークン番号は一致している
  • Zend/zend_language_scanner.lからZend/zend_language_scanner.c(字句解析ルーチン)が生成される
  • 字句解析のルーチンはlex_scan関数として生成されるが、このラッパーとしてzendlex関数がZend/zend_compile.cに定義されている。そして、zendparseが呼び出すyylexは#define yylex zendlexというマクロによってzendlexに置換されるので、関係性としては少々複雑である。
  • Zend/zend_language_parser.yからZend/zend_language_parser.c(構文解析ルーチン)が生成される
  • 構文解析のルーチンはyyparse関数として生成されるが#define yyparse zendparseというマクロによって置換されるので呼び出す側はzendparseとして呼び出すこと

open_file_for_scanning関数によってファイルの内容が内部のバッファに保存されるので、これをもとに字句解析ルーチンに処理を移します。

通常、字句解析ルーチンと構文解析ルーチンが同時に存在するようなプログラムでは構文解析ルーチンが字句解析ルーチンの上位プログラムになります。PHPでのコンパイル処理においてもそうなっていて、基本的には以下の処理になります。

  1. zendpars関数(構文解析)が実行される
  2. zendpars内部でzendlex関数を呼び出して字句解析処理に移行する
  3. zendlex関数からlex_scan関数(字句解析)を呼び出し構文解析ルーチンの処理対象となるトークンを見つけるとzendlex関数に復帰しつつzendparse関数へ値を返却し、zendpars関数内で必要なアクションを行う(zend_ast_create_xxx系の関数を利用した抽象構文木の生成など)。

余談ですが、zend_ast_create_xxx系の関数には少々技巧的なマクロが使われていて最初読んだ時理解できなかったのですが、自分でテストでプリプロセッサを動かしてみたら理解できました。引数の数によって関数を変化させているようです。

https://twitter.com/tajima_taso/status/1353026224138817536?s=20
Zend/zend_language_scanner.l
static zend_op_array *zend_compile(int type)
{
	zend_op_array *op_array = NULL;
	zend_bool original_in_compilation = CG(in_compilation);

	CG(in_compilation) = 1;
	CG(ast) = NULL;
	CG(ast_arena) = zend_arena_create(1024 * 32);

	if (!zendparse()) {
	#省略
	}

※ちなみにzendparse関数は成功したら0を返すので直感とはやや反しますが、!zendparse()で成功の条件になります。

オペコード生成

Zend/zend_language_scanner.l
	if (!zendparse()) {
		int last_lineno = CG(zend_lineno);
		zend_file_context original_file_context;
		zend_oparray_context original_oparray_context;
		zend_op_array *original_active_op_array = CG(active_op_array);

		op_array = emalloc(sizeof(zend_op_array));
		init_op_array(op_array, type, INITIAL_OP_ARRAY_SIZE);
		CG(active_op_array) = op_array;

		/* Use heap to not waste arena memory */
		op_array->fn_flags |= ZEND_ACC_HEAP_RT_CACHE;

		if (zend_ast_process) {
			zend_ast_process(CG(ast));
		}

		zend_file_context_begin(&original_file_context);
		zend_oparray_context_begin(&original_oparray_context);
		zend_compile_top_stmt(CG(ast));
		CG(zend_lineno) = last_lineno;
		zend_emit_final_return(type == ZEND_USER_FUNCTION);
		op_array->line_start = 1;
		op_array->line_end = last_lineno;
		pass_two(op_array);
		zend_oparray_context_end(&original_oparray_context);
		zend_file_context_end(&original_file_context);

		CG(active_op_array) = original_active_op_array;
	}

以上の字句解析および構文解析が問題なく終了したら、抽象構文木(AST)の情報をもとにzend_compile_top_stmt関数を起点にして、オペコードの生成を行っていきます。

この処理は再帰的に処理されるようになっているので、復帰時にはop_arrayにオペコードに関する情報が格納されています。

このコード上ではわからないですが、CG(active_op_array) = op_arrayの処理で、コンパイル処理におけるグローバル変数CG(active_op_array)にop_arrayの情報が格納されているので、コンパイル処理内ではこの変数をもとにzend_emit_xxx系関数を使ってオペコードの値を代入しています。

ここでop_arrayというPHPの実行において非常に重要なシンボルが登場しました。これはオペコードや、オペコードの実行対象となるハンドラなどプログラムの実行において主役となるzend_opというデータ構造の集合です。

Zend/zend_compile.h

struct _zend_op {
        const void *handler;
        znode_op op1;
        znode_op op2;
        znode_op result;
        uint32_t extended_value;
        uint32_t lineno;
        zend_uchar opcode;
        zend_uchar op1_type;
        zend_uchar op2_type;
        zend_uchar result_type;
};

struct _zend_op_array {
	/* Common elements */
	zend_uchar type;
	zend_uchar arg_flags[3]; /* bitset of arg_info.pass_by_reference */
	uint32_t fn_flags;
	zend_string *function_name;
	zend_class_entry *scope;
	zend_function *prototype;
	uint32_t num_args;
	uint32_t required_num_args;
	zend_arg_info *arg_info;
	HashTable *attributes;
	/* END of common elements */

	int cache_size;     /* number of run_time_cache_slots * sizeof(void*) */
	int last_var;       /* number of CV variables */
	uint32_t T;         /* number of temporary variables */
	uint32_t last;      /* number of opcodes */

	zend_op *opcodes;
	ZEND_MAP_PTR_DEF(void **, run_time_cache);
	ZEND_MAP_PTR_DEF(HashTable *, static_variables_ptr);
	HashTable *static_variables;
	zend_string **vars; /* names of CV variables */

	uint32_t *refcount;

	int last_live_range;
	int last_try_catch;
	zend_live_range *live_range;
	zend_try_catch_element *try_catch_array;

	zend_string *filename;
	uint32_t line_start;
	uint32_t line_end;
	zend_string *doc_comment;

	int last_literal;
	zval *literals;

	void *reserved[ZEND_MAX_RESERVED_RESOURCES];
};

話を本筋に戻しましょう。この段階でop_arrayはまだオペコードを生成した段階に過ぎません。

PHPはマルチパスのコンパイラ方式を採用しているようで後続のpass_two関数でオペコードに対するハンドラやその他実行に関する設定を行っています。

この多段階での最適化はOPCacheやJIT内での最適化処理でも見られるので、全体的にPHP8は各レイヤーでの最適化がしやすい構造になっています。

さて、検証スクリプトを実行させたところ、僕の環境ではexe.phpに対して8つオペコードが生成され、それぞれ下記のようなハンドラが設定されました。

op->opcode:ZEND_INCLUDE_OR_EVAL op->handler:&&ZEND_INCLUDE_OR_EVAL_SPEC_CONST_LABEL
op->opcode:ZEND_INIT_FCALL_BY_NAME op->handler:&&ZEND_INIT_FCALL_BY_NAME_SPEC_CONST_LABEL
op->opcode:ZEND_DO_FCALL_BY_NAME op->handler:&&ZEND_DO_FCALL_BY_NAME_SPEC_RETVAL_UNUSED_LABEL 
op->opcode:ZEND_INIT_FCALL_BY_NAME op->handler:&&ZEND_INIT_FCALL_BY_NAME_SPEC_CONST_LABEL
op->opcode:ZEND_DO_FCALL_BY_NAME op->handler:&&ZEND_DO_FCALL_BY_NAME_SPEC_RETVAL_UNUSED_LABEL
op->opcode:ZEND_INIT_FCALL_BY_NAME op->handler:&&ZEND_INIT_FCALL_BY_NAME_SPEC_CONST_LABEL
op->opcode:ZEND_DO_FCALL_BY_NAME op->handler:&&ZEND_DO_FCALL_BY_NAME_SPEC_RETVAL_UNUSED_LABEL
op->opcode:ZEND_RETURN op->handler:&&ZEND_RETURN_SPEC_CONST_LABEL

ちなみに、これはexe.phpのみのオペコード群であることに注意してください。

func.phpのオペコードついては、PHPのソースコード内のrequire_once式に対応するZEND_INCLUDE_OR_EVALに対するハンドラの実行によって、上記で行ったような字句解析処理からはじまる一連のコンパイル処理を経て生成されます。

ちなみにハンドラの&&ZEND_RETURN_SPEC_CONST_LABELのような記述は、GCCの拡張機能の一つでラベルのアドレスを表しています。

つまり、オペコードに紐付いているハンドラが指しているのは関数ポインタではなくC言語のラベルということになります。

一部抜粋しますが、Zend/zend_vm_execute.h内の下記の処理でラベルは配列として定義されています。

Zend/zend_vm_execute.h
if (UNEXPECTED(execute_data == NULL)) {
		static const void * const labels[] = {
			(void*)&&ZEND_NOP_SPEC_LABEL,
			(void*)&&ZEND_ADD_SPEC_CONST_CONST_LABEL,
			(void*)&&ZEND_ADD_SPEC_CONST_TMPVARCV_LABEL,
			(void*)&&ZEND_ADD_SPEC_CONST_TMPVARCV_LABEL,
			(void*)&&ZEND_NULL_LABEL,
			(void*)&&ZEND_ADD_SPEC_CONST_TMPVARCV_LABEL,
			(void*)&&ZEND_ADD_SPEC_TMPVARCV_CONST_LABEL,
			(void*)&&ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_LABEL,
			(void*)&&ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_LABEL,
			(void*)&&ZEND_NULL_LABEL,
			(void*)&&ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_LABEL,
			(void*)&&ZEND_ADD_SPEC_TMPVARCV_CONST_LABEL,
			(void*)&&ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_LABEL,
			(void*)&&ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_LABEL,
			(void*)&&ZEND_NULL_LABEL,
			(void*)&&ZEND_ADD_SPEC_TMPVARCV_TMPVARCV_LABEL,
			(void*)&&ZEND_NULL_LABEL,
			(void*)&&ZEND_NULL_LABEL,
			(void*)&&ZEND_NULL_LABEL,
			(void*)&&ZEND_NULL_LABEL,
			(void*)&&ZEND_NULL_LABEL,
#ずっと続く

pass_two関数では最終的にzend_vm_set_opcode_handler関数でハンドラを設定することになるのですが、このハンドラの決定にあたってzend_vm_get_opcode_handler_idx関数がオペコードのコンテキストに合わせたハンドラ配列内のインデックスを取得してくれるので、その添字を用いて該当のハンドラを設定しています。

ところで、このZend/zend_vm_execute.hというオペコード実行の中心となるファイルなのですが、人の手でコーディングしているものではありません。

僕が最初このファイルを見た時、ハンドラ配列の作成やその他関数の処理は機械的に出力されている印象を受けました。字句解析ルーチンや構文解析ルーチンを見た時の感覚に似ていたのです。

Zend Engineに関するREADMEをみると実際そうなっていまして、zend_vm_def.hおよびzend_vm_execute.sklをテンプレート的に利用してzend_vm_execute.hおよびzend_vm_opcodes.hが生成される仕組みになっています。そして、面白いことにその生成処理にはPHPが利用されているのです。


$ php zend_vm_gen.php --with-vm-kind=CALL

よって、zend_vm_execute.hあるいはzend_vm_opcodes.hに変更がある場合は、zend_vm_def.hやzend_vm_execute.sklを変更した後、上記のようにPHPスクリプトを実行して生成します。

zend_vm_gen.phpはPHP5でも余裕で動きそうなレガシーな文法でコーディングされているので実行するPHPのバージョンはビルド対象のPHPと同一である必要はなさそうです。

オペコードの実行

zend_compile_file関数から復帰後のop_arrayを引数にして、zend_execute関数を実行します。ここから実行処理に遷移します。

Zend/zend.c
		op_array = zend_compile_file(file_handle, type);
		if (file_handle->opened_path) {
			zend_hash_add_empty_element(&EG(included_files), file_handle->opened_path);
		}
		zend_destroy_file_handle(file_handle);
		if (op_array) {
			zend_execute(op_array, retval);
			zend_exception_restore();
			if (UNEXPECTED(EG(exception))) {
				if (Z_TYPE(EG(user_exception_handler)) != IS_UNDEF) {
					zend_user_exception_handler();
				}
				if (EG(exception)) {
					ret = zend_exception_error(EG(exception), E_ERROR);
				}
			}
			destroy_op_array(op_array);
			efree_size(op_array, sizeof(zend_op_array));
		} else if (type==ZEND_REQUIRE) {
			ret = FAILURE;
		}
	}

zend_execute関数内のzend_vm_stack_push_call_frame関数にて、これから実行するオペコードの処理をサブルーチンとみなして、コールスタックの作成を行います。いわばPHPの世界でのABIのようなイメージかと思います。それらは、zend_execute_dataというデータ構造になっています。

ネイティブなマシン語の実行と同様に、PHPのオペコードの処理もあるオペコードの処理から別のオペコードの処理を呼び出すということがありうるのでスタックフレームをデータ構造と用意しているのだと思います。

Zend/zend_vm_execute.h
ZEND_API void zend_execute(zend_op_array *op_array, zval *return_value)
{
	zend_execute_data *execute_data;
	void *object_or_called_scope;
	uint32_t call_info;

	if (EG(exception) != NULL) {
		return;
	}

	object_or_called_scope = zend_get_this_object(EG(current_execute_data));
	if (EXPECTED(!object_or_called_scope)) {
		object_or_called_scope = zend_get_called_scope(EG(current_execute_data));
		call_info = ZEND_CALL_TOP_CODE | ZEND_CALL_HAS_SYMBOL_TABLE;
	} else {
		call_info = ZEND_CALL_TOP_CODE | ZEND_CALL_HAS_SYMBOL_TABLE | ZEND_CALL_HAS_THIS;
	}
	execute_data = zend_vm_stack_push_call_frame(call_info,
		(zend_function*)op_array, 0, object_or_called_scope);
	if (EG(current_execute_data)) {
		execute_data->symbol_table = zend_rebuild_symbol_table();
	} else {
		execute_data->symbol_table = &EG(symbol_table);
	}
	EX(prev_execute_data) = EG(current_execute_data);
	i_init_code_execute_data(execute_data, op_array, return_value);
	ZEND_OBSERVER_FCALL_BEGIN(execute_data);
	zend_execute_ex(execute_data);
	/* Observer end handlers are called from ZEND_RETURN */
	zend_vm_stack_free_call_frame(execute_data);
}

i_init_code_execute_data関数で、EX(opline)やEG(current_execute_data)といった実行時のグローバル変数に対して初期化処理を行った後に、zend_execute_ex関数を呼び出します。

ちなみに、EX(xx)という表記はシングルスレッド環境で動かしているいわゆるNon Thread Safeな環境においては、下記のようなマクロ展開がなされます。

# define EG(v) (executor_globals.v)

冒頭で紹介したようにzend_execute_exにはexecute_exの関数アドレスが代入されているので、実際はexecute_exが呼び出されます。

Zend/zend_vm_execute.h
ZEND_API void execute_ex(zend_execute_data *ex)
{
#大胆に省略

LOAD_OPLINE();
	ZEND_VM_LOOP_INTERRUPT_CHECK();

	while (1) {
#if !defined(ZEND_VM_FP_GLOBAL_REG) || !defined(ZEND_VM_IP_GLOBAL_REG)
			int ret;
#endif
#if (ZEND_VM_KIND == ZEND_VM_KIND_HYBRID)
		HYBRID_SWITCH() {
#else
#if defined(ZEND_VM_FP_GLOBAL_REG) && defined(ZEND_VM_IP_GLOBAL_REG)
		((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
		if (UNEXPECTED(!OPLINE)) {
#else
		if (UNEXPECTED((ret = ((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU)) != 0)) {
#endif
#endif

LOAD_OPLINEというマクロによってopline = EX(opline)が実行され処理対象のoplineが取り出されます。

Zend/zend_vm_execute.h
while (1) {
#if !defined(ZEND_VM_FP_GLOBAL_REG) || !defined(ZEND_VM_IP_GLOBAL_REG)
			int ret;
#endif
#if (ZEND_VM_KIND == ZEND_VM_KIND_HYBRID)
		HYBRID_SWITCH() {
#else
#if defined(ZEND_VM_FP_GLOBAL_REG) && defined(ZEND_VM_IP_GLOBAL_REG)
		((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
		if (UNEXPECTED(!OPLINE)) {
#else
		if (UNEXPECTED((ret = ((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU)) != 0)) {
#endif
#endif
#if (ZEND_VM_KIND == ZEND_VM_KIND_HYBRID)
			HYBRID_CASE(ZEND_ASSIGN_STATIC_PROP_OP_SPEC):
				VM_TRACE(ZEND_ASSIGN_STATIC_PROP_OP_SPEC)
				ZEND_ASSIGN_STATIC_PROP_OP_SPEC_HANDLER(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
				HYBRID_BREAK();
			HYBRID_CASE(ZEND_PRE_INC_STATIC_PROP_SPEC):
				VM_TRACE(ZEND_PRE_INC_STATIC_PROP_SPEC)
				ZEND_PRE_INC_STATIC_PROP_SPEC_HANDLER(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
				HYBRID_BREAK();
			HYBRID_CASE(ZEND_POST_INC_STATIC_PROP_SPEC):
				VM_TRACE(ZEND_POST_INC_STATIC_PROP_SPEC)
#省略

ここからoplineを処理する為にジャンプを繰り返していきますが、このマクロだとパッと見よくわからないので展開してみます。

すると、opline->handlerの指すラベルに対してジャンプする処理になっていることがわかります。

Zend/zend_vm_execute.h
while (1) {

goto *(void**)(opline->handler);{

ZEND_ASSIGN_STATIC_PROP_OP_SPEC_LABEL:
	zend_vm_trace("ZEND_ASSIGN_STATIC_PROP_OP_SPEC", sizeof("ZEND_ASSIGN_STATIC_PROP_OP_SPEC")-1);
	ZEND_ASSIGN_STATIC_PROP_OP_SPEC_HANDLER();
	goto *(void**)(opline->handler);
ZEND_PRE_INC_STATIC_PROP_SPEC_LABEL:
	zend_vm_trace("ZEND_PRE_INC_STATIC_PROP_SPEC", sizeof("ZEND_PRE_INC_STATIC_PROP_SPEC")-1);
	ZEND_PRE_INC_STATIC_PROP_SPEC_HANDLER();
	goto *(void**)(opline->handler);
ZEND_POST_INC_STATIC_PROP_SPEC_LABEL:
#以下続く

最初のオペコードはZEND_INCLUDE_OR_EVALですが、それに対するハンドラ(opline->handler)はZEND_INCLUDE_OR_EVAL_SPEC_CONST_LABELなのでそこにジャンプし、そこからZEND_INCLUDE_OR_EVAL_SPEC_CONST_HANDLER関数を呼び出します。

Zend/zend_vm_execute.h
HYBRID_CASE(ZEND_INCLUDE_OR_EVAL_SPEC_CONST):
				VM_TRACE(ZEND_INCLUDE_OR_EVAL_SPEC_CONST)
				ZEND_INCLUDE_OR_EVAL_SPEC_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
				HYBRID_BREAK();

該当の処理の詳細は割愛しますが、流れとしてはfunc.phpに対してcompile_fileの処理を適用します。その結果、再びここまでと同様の処理が行われます。

func.phpでは関数が宣言されているので、途中zend_compile_func_decl関数によってhoge関数が関数のハッシュテーブルに登録されます。これによって以降のスクリプトでは関数が呼び出し可能になります。

hoge関数内で実行されるオペコードとしては以下になりました。

op->opcode:ZEND_ECHO op->handler:&&ZEND_ECHO_SPEC_CONST_LABEL
op->opcode:ZEND_ECHO op->handler:&&ZEND_ECHO_SPEC_CONST_LABEL
op->opcode:ZEND_ECHO op->handler:&&ZEND_ECHO_SPEC_CONST_LABEL
op->opcode:ZEND_ECHO op->handler:&&ZEND_ECHO_SPEC_CONST_LABEL
op->opcode:ZEND_ECHO op->handler:&&ZEND_ECHO_SPEC_CONST_LABEL
op->opcode:ZEND_RETURN op->handler:&&ZEND_RETURN_SPEC_CONST_LABEL

ちなみにfunc.phpでhoge関数の宣言以外に行われる処理はないので、func.php内で実行されるオペコードはZEND_RETURNのみです。

ZEND_RETURNの実行によって実行処理はexe.phpに復帰し、ZEND_INIT_FCALL_BY_NAMEから開始されます。

このオペコードの実行によってコールスタックの生成や、関数に登録されたオペコードの設定など関数の実行に必要な初期化処理が行われ、続くZEND_DO_FCALL_BY_NAMEの実行によって上記のZEND_ECHOが15回(5x3)実行されます。

PHPのプログラムの言語構造の一つであるechoはsapi_module.ub_writeという汎用の関数ポインタから実行され、その実体はCLI SAPIの場合sapi_cli_ub_write関数が該当します。

辿っていくと最終的にはC言語標準ライブラリのwriteを実行していました。

余談ですが、PHPの中で関数と言語構造の違いでモヤモヤした場合はZend/zend_language_parser.yをあたりを眺めてみるのが一番かもしれません。

$ php exe.php
hogehogehogehogehogehogehogehogehogehogehogehogehogehogehoge

PHPの利用者から見ると実に簡潔な処理ですが、CPUの気持ちになって眺めてみると実にたくさんのことをやっていることがわかりますね。

では、次はこれを最適化した処理を見ていきましょう。

OPcache有効な状態

ここまで熱心に読んで頂いた方、お疲れ様でした。正直、僕としてはこれまでのところまで理解頂ければ十分と思っているので残りは簡単に紹介したいと思います。

というのも、OPcacheを有効にした処理もJITを有効化した処理も、コンパイルの処理と実行処理が高度に最適化された以外に大きな違いがなく普通の人がPHPの処理を理解するには既に十分だと思っているからです。

もちろん、まさにその高度に最適化した部分こそが実に奥深く面白いところなのですが、その部分についてはまた別の場で発表できたらと思っております。詳細等はSNS等を注目頂けると嬉しいです。

さて、OPcacheの有効化はコマンド実行時に指定して行うことにします。

$ php -d zend_extension=opcache.so -d opcache.enable_cli=1 -d opcache.optimization_level=0x7FFFFFFF exe.php

なお、OPcache無効な状態を読んだ方なら省略しても問題ないであろうということは大胆に割愛したいと思います。

初期化処理

今回はモジュールの読み込みが行われていますので、そこの処理について触れておきます。PHPのモジュールもSAPIと同様特別な規約があります。それに従ってスタートアップの関数が実行されるのですがOPcacheの場合はaccel_startup関数が実行されます。

その中でzend_post_startup_cbという関数ポインタにaccel_post_startup関数のアドレスを代入しています。

ext/opcache/ZendAccelerator.c
zend_post_startup_cb = accel_post_startup

zend_post_startup_cbはzend_post_startup関数の中で呼び出され、下記の代入を行います。

ext/opcache/ZendAccelerator.c
accelerator_orig_compile_file = zend_compile_file;
zend_compile_file = persistent_compile_file;

何だか見覚えがある感じじゃないでしょうか?

OPcache無効時にもあったzend_compile_fileへの代入処理です。その時は、zend_compile_fileの実体はcompile_file関数でした。OPcacheの場合はこれがpersistent_compile_file関数になっています。

コンパイルを行う機能だけを提供していたcompile_file関数に対して、OPcacheの有効時においてはキャッシュ機能と最適化機能が追加されているpersistent_compile_file関数がzend_compile_fileに設定されています。

ちなみに、accelerator_orig_compile_fileという関数ポインタにオリジナルのzend_compile_file(compile_file)を代入しているのは、字句解析と構文解析処理に関してはcompile_file関数の処理をそのまま利用したいからです。

オペコードの生成

OPcache有効化時でのコンパイル処理は下記のようになっています。

ext/opcache/ZendAccelerator.c
zend_try {
		orig_compiler_options = CG(compiler_options);
		CG(compiler_options) |= ZEND_COMPILE_HANDLE_OP_ARRAY;
		CG(compiler_options) |= ZEND_COMPILE_IGNORE_INTERNAL_CLASSES;
		CG(compiler_options) |= ZEND_COMPILE_DELAYED_BINDING;
		CG(compiler_options) |= ZEND_COMPILE_NO_CONSTANT_SUBSTITUTION;
		CG(compiler_options) |= ZEND_COMPILE_IGNORE_OTHER_FILES;
		if (ZCG(accel_directives).file_cache) {
			CG(compiler_options) |= ZEND_COMPILE_WITH_FILE_CACHE;
		}
		op_array = *op_array_p = accelerator_orig_compile_file(file_handle, type);
		CG(compiler_options) = orig_compiler_options;
	} zend_catch {
		op_array = NULL;
		do_bailout = 1;
		CG(compiler_options) = orig_compiler_options;
	} zend_end_try();

上記はOPcache無効の処理と同じ処理を行っています。つまり、OPcache有効化状態ではOPcache無効の状態の処理を余分に挟んでいるぶん処理に時間がかかる気がしてきます。

OPcacheはパフォーマンスを向上させる機能なのでもちろん遅くなるということはなく、後続のオペコードの最適化処理や共有メモリへの保存処理等でコンパイル処理のカットや、実行命令の削減が行われるので長期的に使う場合は特にパフォーマンスを向上が期待できます。

実際に構文解析処理後のオペコードへのハンドラ登録処理までを観察してみると、まったく同じop_arrayが生成されました。

op->opcode:ZEND_INCLUDE_OR_EVAL op->handler:&&ZEND_INCLUDE_OR_EVAL_SPEC_CONST_LABEL
op->opcode:ZEND_INIT_FCALL_BY_NAME op->handler:&&ZEND_INIT_FCALL_BY_NAME_SPEC_CONST_LABEL
op->opcode:ZEND_DO_FCALL_BY_NAME op->handler:&&ZEND_DO_FCALL_BY_NAME_SPEC_RETVAL_UNUSED_LABEL 
op->opcode:ZEND_INIT_FCALL_BY_NAME op->handler:&&ZEND_INIT_FCALL_BY_NAME_SPEC_CONST_LABEL
op->opcode:ZEND_DO_FCALL_BY_NAME op->handler:&&ZEND_DO_FCALL_BY_NAME_SPEC_RETVAL_UNUSED_LABEL
op->opcode:ZEND_INIT_FCALL_BY_NAME op->handler:&&ZEND_INIT_FCALL_BY_NAME_SPEC_CONST_LABEL
op->opcode:ZEND_DO_FCALL_BY_NAME op->handler:&&ZEND_DO_FCALL_BY_NAME_SPEC_RETVAL_UNUSED_LABEL
op->opcode:ZEND_RETURN op->handler:&&ZEND_RETURN_SPEC_CONST_LABEL

オペコードの最適化

その後、cache_script_in_shared_memory関数にて最適化処理を施した後、最終的なオペコード実行直前までに生成したデータ構造をzend_accel_script_persist関数で共有メモリに保存します。

ちなみに最適化処理はzend_optimize_script関数にて行われるのですが、最適化のレベルにはいくつか段階があり、実行時やiniファイル等で設定可能です。

ext/opcache/Optimizer/zend_optimizer.h
#define ZEND_OPTIMIZER_PASS_1		(1<<0)   /* Simple local optimizations   */
#define ZEND_OPTIMIZER_PASS_2		(1<<1)   /*                              */
#define ZEND_OPTIMIZER_PASS_3		(1<<2)   /* Jump optimization            */
#define ZEND_OPTIMIZER_PASS_4		(1<<3)   /* INIT_FCALL_BY_NAME -> DO_FCALL */
#define ZEND_OPTIMIZER_PASS_5		(1<<4)   /* CFG based optimization       */
#define ZEND_OPTIMIZER_PASS_6		(1<<5)   /* DFA based optimization       */
#define ZEND_OPTIMIZER_PASS_7		(1<<6)   /* CALL GRAPH optimization      */
#define ZEND_OPTIMIZER_PASS_8		(1<<7)   /* SCCP (constant propagation)  */
#define ZEND_OPTIMIZER_PASS_9		(1<<8)   /* TMP VAR usage                */
#define ZEND_OPTIMIZER_PASS_10		(1<<9)   /* NOP removal                 */
#define ZEND_OPTIMIZER_PASS_11		(1<<10)  /* Merge equal constants       */
#define ZEND_OPTIMIZER_PASS_12		(1<<11)  /* Adjust used stack           */
#define ZEND_OPTIMIZER_PASS_13		(1<<12)  /* Remove unused variables     */
#define ZEND_OPTIMIZER_PASS_14		(1<<13)  /* DCE (dead code elimination) */
#define ZEND_OPTIMIZER_PASS_15		(1<<14)  /* (unsafe) Collect constants */
#define ZEND_OPTIMIZER_PASS_16		(1<<15)  /* Inline functions */

#define ZEND_OPTIMIZER_IGNORE_OVERLOADING	(1<<16)  /* (unsafe) Ignore possibility of operator overloading */

#define ZEND_OPTIMIZER_ALL_PASSES	0x7FFFFFFF

#define DEFAULT_OPTIMIZATION_LEVEL  "0x7FFEBFFF"

今回CLI実行時のパラメータとして opcache.optimization_level=0x7FFFFFFFを指定しましたがこのZEND_OPTIMIZER_ALL_PASSESの値を指定しています。

PHPのマニュアルには一部のパラメータ指定しか書いてないこともよくあるので、思い切ってソースコードのヘッダファイルから読み解いて指定するのも一つの方法です。

全部の最適化レベルを適用してみましたが、単純過ぎる今回のスクリプトではこの処理による画期的な最適化はなされなかったように思えます(一時変数が0になっているくらい)。下記の2つは最適化前後でのexe.phpでオペコード上の状態です。

$_main:
     ; (lines=8, args=0, vars=0, tmps=4)
     ; (before optimizer)
     ; /root/php/exe.php:1-8
     ; return  [] RANGE[0..0]
0000 INCLUDE_OR_EVAL (require_once) string("func.php")
0001 INIT_FCALL_BY_NAME 0 string("hoge")
0002 DO_FCALL_BY_NAME
0003 INIT_FCALL_BY_NAME 0 string("hoge")
0004 DO_FCALL_BY_NAME
0005 INIT_FCALL_BY_NAME 0 string("hoge")
0006 DO_FCALL_BY_NAME
0007 RETURN int(1)
$_main:
     ; (lines=8, args=0, vars=0, tmps=0)
     ; (after optimizer)
     ; /root/php/exe.php:1-8
0000 INCLUDE_OR_EVAL (require_once) string("func.php")
0001 INIT_FCALL_BY_NAME 0 string("hoge")
0002 DO_FCALL_BY_NAME
0003 INIT_FCALL_BY_NAME 0 string("hoge")
0004 DO_FCALL_BY_NAME
0005 INIT_FCALL_BY_NAME 0 string("hoge")
0006 DO_FCALL_BY_NAME
0007 RETURN int(1)

この最適化をもって、zend_persistent_scriptというデータ構造を共有メモリに保存します。キャッシュのキーがどういうものか気になったので見てみたら、exe.php:2277328:2222600のような形式になっていました。

2回目移行このexe.phpが実行された場合は、persistent_compile_file関数のこの箇所で即座にキャッシュを取得して早急に実行処理に移行できる為、大幅に処理時間を削減できます。

もしキャッシュが古くなってしまった場合は、上記の処理を行い再びキャッシュへの保存を行います。

では、func.phpのhoge関数についてはどうでしょうか? オペコードの最適化前後を比較してみます。

hoge:
     ; (lines=6, args=0, vars=0, tmps=0)
     ; (before optimizer)
     ; /root/php/func.php:3-9
     ; return  [] RANGE[0..0]
0000 ECHO string("hoge")
0001 ECHO string("hoge")
0002 ECHO string("hoge")
0003 ECHO string("hoge")
0004 ECHO string("hoge")
0005 RETURN null
hoge:
     ; (lines=2, args=0, vars=0, tmps=0, no_loops)
     ; (after pass 13)
     ; /root/php/func.php:3-9
     ; return  [null] RANGE[0..0]
0000 ECHO string("hogehogehogehogehoge")
0001 RETURN null

こちらに関しては最適化が行われたことが明確ですね。ECHOの出力が一度にまとめられています。細かく見ていくと、どうやら制御フローグラフ(CFG)による最適化が効いているようです。オペコードとハンドラの関係は下記のようになります。

op->opcode:ZEND_ECHO op->handler:&&ZEND_ECHO_SPEC_CONST_LABEL
op->opcode:ZEND_RETURN op->handler:&&ZEND_RETURN_SPEC_CONST_LABEL

もちろんこのfunc.phpもキャッシュされますので、exe.phpとあわせて大幅な処理時間短縮が見込まれます。

オペコードの実行

最終的な結果としては、ZEND_ECHOの実行が3回(1x3)という結果になりました。OPcache無効化の時は15回実行されていたので__5分の1__に削減されています。また、php-fpm等で実行されている場合はキャッシュのヒット率が高まりますのでオペコードの実行以前の時間も合わせて大幅な処理時間の短縮が期待できます。

OPcache有効かつJITが有効な状態

では、最後にPHP8の注目機能であるJITについて解説します。JITに関してはOPcacheの機能の一部という位置づけなので最終的に生成されるオペコードとそのハンドラ以外はOPcacheと同じと思って頂いて問題ないかと思います。

OPcache有効状態でも実行されるハンドラのアドレスはOPcache無効状態と同じでした。つまりC言語のラベルです。

一方、JITではハンドラ自体が既存と全然違うものになります。ハンドラはOPcacheの最適化に加えてJIT処理による最適化がプラスされた上で出力されたネイティブコードのアドレスを指します。

op->opcode:ZEND_ECHO op->handler:?????? # どこか

OPcache同様JITもCLI実行時に有効化してみます。


php8 -d opcache.jit_debug=0xFFFFF -d zend_extension=opcache.so -d opcache.enable_cli=1 -d opcache.jit=1205 -d opcache.jit_buffer_size=128M -d opcache.opt_debug_level=0x7FFFFFFF exe.php

なお、最適化レベルは最高の5(スクリプト全体を最適化する)にしてます。最適化レベルによっては今回解説する処理と異なってくるので注意して下さい。OPcache同様ヘッダファイル直接みると良いかもしれません。

ちなみに最適化は下の数字の最適化を含んでいるという構造になっているので、5であれば4以下の最適化も実施されます。1だとほぼ最適化されないですが数値をあげて段階的に見ていくと変化が面白いです。

ext/opcache/jit/zend_jit.h
#define ZEND_JIT_LEVEL_NONE        0     /* no JIT */
#define ZEND_JIT_LEVEL_MINIMAL     1     /* minimal JIT (subroutine threading) */
#define ZEND_JIT_LEVEL_INLINE      2     /* selective inline threading */
#define ZEND_JIT_LEVEL_OPT_FUNC    3     /* optimized JIT based on Type-Inference */
#define ZEND_JIT_LEVEL_OPT_FUNCS   4     /* optimized JIT based on Type-Inference and call-tree */
#define ZEND_JIT_LEVEL_OPT_SCRIPT  5     /* optimized JIT based on Type-Inference and inner-procedure analysis */

初期化処理

JITはOPcacheの一部として実装されているので、初期化処理は特筆すべきところは特にないと思っています。一番違いが出てくるzend_compile_fileへの代入もOPcacheの時と同じものです。

JIT

JIT独自の処理が入り込んでくるのは、共有メモリへの保存処理を担っているzend_accel_script_persist内のzend_jit_script関数です。ZEND_JIT_LEVEL_OPT_SCRIPTは最適化レベルが5を意味するので、それ以上のレベル時に実行されます。

ext/opcache/zend_persist.c
#ifdef HAVE_JIT
	if (JIT_G(on) && for_shm) {
		if (JIT_G(opt_level) >= ZEND_JIT_LEVEL_OPT_SCRIPT) {
			zend_jit_script(&script->script);
		}
		zend_jit_protect();
	}
#endif

zend_jit_script内では型推論の為のSSA構築、コールグラフによる最適化処理等を経てzend_jit関数を実行します。

zend_jit関数内でdasm_link_and_encode関数を実行し、ネイティブコードの生成とハンドラの設定を行います。

ext/opcache/jit/zend_jit.c
handler = dasm_link_and_encode(&dasm_state, op_array, ssa, rt_opline, ra, NULL, 0);
	if (!handler) {
		goto jit_failure;
	}

実際には下記のopline->handler = (void*)(((char*)entry) + offset)でハンドラの差し替えを行っています。

dasmからはじまる命名規則の関数がネイティブコードの生成を担ってくれているのですが、これらはDynAsmというネイティブコード生成エンジンです。JITの実装において有用なツールとされています。

僕はまだまだ理解できていないですが、公式サイトよりもこちらのサイトの方がイメージが掴みやすかったです。今後関わっていくことが多くなる気もするので少しずつ勉強していこうと思います。

ext/opcache/jit/zend_jit.c
	ret = dasm_encode(dasm_state, *dasm_ptr);
	if (ret != DASM_S_OK) {
#if ZEND_DEBUG
		handle_dasm_error(ret);
#endif
		return NULL;
	}

	entry = *dasm_ptr;
	*dasm_ptr = (void*)((char*)*dasm_ptr + ZEND_MM_ALIGNED_SIZE_EX(size, DASM_ALIGNMENT));

	if (trace_num) {
		zend_jit_trace_add_code(entry, size);
	}

	if (op_array && ssa) {
		int b;

		for (b = 0; b < ssa->cfg.blocks_count; b++) {
//#ifdef CONTEXT_THREADED_JIT
//			if (ssa->cfg.blocks[b].flags & (ZEND_BB_START|ZEND_BB_RECV_ENTRY)) {
//#else
			if (ssa->cfg.blocks[b].flags & (ZEND_BB_START|ZEND_BB_ENTRY|ZEND_BB_RECV_ENTRY)) {
//#endif
				zend_op *opline = op_array->opcodes + ssa->cfg.blocks[b].start;
				int offset = dasm_getpclabel(dasm_state, ssa->cfg.blocks_count + b);

				if (offset >= 0) {
					opline->handler = (void*)(((char*)entry) + offset);
				}
			}
		}
	    if (rt_opline && ssa && ssa->cfg.map) {
			int b = ssa->cfg.map[rt_opline - op_array->opcodes];
			zend_op *opline = (zend_op*)rt_opline;
			int offset = dasm_getpclabel(dasm_state, ssa->cfg.blocks_count + b);

			if (offset >= 0) {
				opline->handler = (void*)(((char*)entry) + offset);
			}
		}
	}

exe.php内のネイティブコードは少々長いので割愛し、hoge関数に設定されたハンドラの処理を紹介すると下記のようになっています。php_output_writeを直接呼び出しているので、これまでのハンドラの処理よりCPU命令レベルでは相当コンパクトな命令数になっているのではないでしょうか。

	mov %r15, (%r14)
        mov $0x40a7e1f0, %rdi
        mov $0x14, %rsi
        mov $php_output_write, %rax
        call *%rax
        mov $EG(exception), %rax
        cmp $0x0, (%rax)
        jnz JIT$$exception_handler
        mov 0x10(%r14), %rcx
        test %rcx, %rcx
        jz .L1
        mov $0x1, 0x8(%rcx)
.L1:
        mov 0x28(%r14), %edi
        mov 0x30(%r14), %rax
        mov $EG(current_execute_data), %rdx
        mov %rax, (%rdx)
        test $0x89e0000, %edi
        jnz JIT$$leave_function
        mov $EG(vm_stack_top), %rax
        mov %r14, (%rax)
        mov 0x30(%r14), %r14
        mov $EG(exception), %rax
        cmp $0x0, (%rax)
        mov (%r14), %r15
        jnz JIT$$leave_throw
        add $0x20, %r15
        jmp (%r15)

ハンドラの設定が終わったので、OPcacheの例と同様に共有メモリへの保存を行います。

オペコードハンドラの実体

func.phpの読み込み実行後に復帰したexe.phpではZEND_INIT_FCALL_BY_NAMEに設定されたハンドラのみが実行して終了します。

何故こんなことが起こるのだろうかと長々と出力されたネイティブコードを眺めていたんですが、どうやら、ネイティブコードが設定されたハンドラは1つのオペコードに対する処理なんていう小さな範囲ではなく、いわばスクリプト全体のオペコードに対する処理を実行するようです。

つまり、復帰後のオペコードのハンドラにはネイティブコード化されたスクリプト全体のエントリーアドレスが設定されていたのです。

上記検証スクリプトよりシンプルな別の例で見てみましょう。

下記のようなPHPスクリプトをOPcache有効化状態において実行した場合、従来の感覚だとZEND_ASSIGN、ZEND_STRLEN、ZEND_ECHO、ZEND_RETURNの4つのオペコードに対するハンドラがそれぞれ1つずつ登録されて合計4回のハンドラが実行される気がします。

<?php

$foo = 'hoge';
echo strlen($foo);

JITではそうではなく、下記のようにネイティブコード全体が一つのオペコードのハンドラとして設定されます。

つまり、最初のZEND_ASSIGNに対するハンドラはネイティブコード全体のエントリーポイントとして設定されるので、ZEND_ASSIGNに対するハンドラだけでなくその他スクリプト全体の処理を担うハンドラとして実行されるのです。従って、一見ZEND_ASSIGNのハンドラしか実行されないような処理に見えるのです。(実際JIT環境においてもop_arrayの個数は4つ存在しています)

ちなみに他のオペコードのネイティブコード上のアドレスも取得可能ではあるので(実際op_array内の個々のハンドラにはネイティブコード上のアドレスが設定されています)、必要に応じて実行は可能です。

mov $0x557e2463d858, %rax
        call *%rax
        mov $EG(exception), %rax
        cmp $0x0, (%rax)
        jnz JIT$$exception_handler
        mov $0x557e2463602a, %rax
        call *%rax
        mov $EG(exception), %rax
        cmp $0x0, (%rax)
        jnz JIT$$exception_handler
        mov $0x557e245f1800, %rax
        call *%rax
        mov $EG(exception), %rax
        cmp $0x0, (%rax)
        jnz JIT$$exception_handler
        mov $ZEND_RETURN_SPEC_CONST_LABEL, %rax
        jmp *%rax

終わりに

いかがだったでしょうか?

PHPではレイヤーを重ねる、あるいはモジュールを取り替えるように最適化処理が行われているので開発者にとっては機能拡張がしやすいですし、利用者にとっては動作モードの変更によるデータ不整合等が極力起こらないような作りになっていて個人的には素晴らしいなあと思いました。

ソースコードリーディングは単純に勉強にもなりますし、深く読んでいくと書いた人の思想も感じられてエンジニアにとっては良い独学の時間になるなあとしみじみ思いました。

参考資料

おまけ

https://twitter.com/tajima_taso/status/1348172887623041024?s=20