PHP カンファレンス 2023 10:50 〜 Track2
PHPで PHPのメモリプロファイラを作ろう五十嵐 進士 / sji / sj-i / @sji_ch
View Slide
自己紹介@sji_chSNS上のアイコンは GitHubが自動生成した奴
生まれも育ちも仙台
PHPカンファレンス仙台とかやった
ふつうのサラリーマン株式会社インフィニットループ仙台支社所属スマホゲーのサーバサイドプログラマ地元が仙台や札幌の人とかはぜひ一緒に働きましょう
Agendaメモリとはなんぞや(約 10分)PHPのメモリ管理機構について(約 10分)PHPのメモリ消費量の計測方法(約 10分)PHPのメモリプロファイラを自作する取り組み(約 15分)
メモリの概要
メモリはコンピュータの部品コンピュータは様々な部品から構成メモリはその中の一つ
コンピュータは数値で動く2つの状態を持つものの並び =信号 =数値電気の強弱磁性体の向き0と 1この信号パターンならこう動く、というのが機械への命令命令を動作の種類と内容を分けて構成すれば様々な情報が数値に
CPUは鳥頭CPUが命令を処理CPU自体では多くの命令を覚えられないCPUが命令を読み取るための装置が必要
メモリが数値の並び(=情報)を大量に記憶メモリが CPUのために情報を記憶CPUはメモリから情報を読んでメモリに情報を書き込む
扱うデータの単位1ビット 0か 1のどちらかの値を持つ単位1バイト 8ビットの並び2の 8乗で 0から 255までの 256種類の値を持つ1キロバイト 1,000バイト or 1,024バイト1メガバイト 1,000キロバイト or 1,024キロバイト1ギガバイト 1,000メガバイト or 1,024メガバイト
メモリはバイト単位のデータの並びメモリはバイトの情報をおさめた箱の並び一つ一つの箱に背番号 =アドレス、番地ある番地や番地の範囲をメモリ領域と呼ぶ
メモリは限られた資源メモリは CPUに大量に読み書きされるメモリが遅いほど CPUが待たされることにメモリは比較的高速に動作するが、高価で大容量化が大変ついでに通電しなくなると記録していた情報が消えるストレージと組み合わせてやりくり遅くても安く大容量化でき、通電しなくても情報が消えない
メモリは OSが管理コンピュータ上では複数プログラムが同時に動くマルチプロセスOSがプロセス間でハードウェア資源の利用を仲介メモリも OSが管理プロセスは OSにメモリを要求OSはプロセス間を仮想メモリ空間で隔離しながらメモリを割り当てプロセスは自分だけのメモリ領域を使っているように見えるストレージとも組み合わせてやりくり(スワップ)
それでも無い袖は振れないストレージはメモリと比べて圧倒的に遅いスワップが発生すると CPUが全然パワーを発揮できない設定でスワップを切ったり制限した状態で動かすこともよくあるメモリが本当に足りなくなると OOM Killerにプロセスを殺されたりする各プログラムでなるべく無断のないメモリの使い方が必要
PHPのメモリ管理機構:memory_limitあたり
Allowed memory size of 134217728 bytes exhausted(tried to allocate 4096 bytes)
memory_limitとはリクエストごとのメモリ上限を指定する ini項目安全装置PHPはマルチプロセスでリクエストを処理マシンリソースを食いつぶさないよう制限できるリクエストの処理が memory_limitを越えたらプロセスが自刃する最近のデフォルト値は 128MBプロジェクトや利用箇所によって違う設定にしてるはず
ZendMemoryManagerPHP処理系のメモリ管理部品2種類のメモリ領域リクエストごとのメモリ領域処理系や C拡張は libcの mallocや freeをパクった emallocや efreeなどの APIで操作永続メモリ領域通常 libcの mallocとか freeが使われるどちらも最終的には OSからもらう仮想メモリ領域を使う名前のZendはZeevとAndiの名前から
基本はリクエストごとのメモリ領域に入るPHPスクリプトのメモリ状態は通常リクエストごとにリセット大部分のデータをリクエストごとのメモリ領域で管理リークの心配があまりなくなるmemory_limitでの制限対象はこっちのメモリ領域memory_get_usage()などでとる情報もこの部分
memory_limitの制限の対象外永続メモリ領域や Zend Memory Manager管理外の領域 は memory_limitの対象外たとえば Xdebugが動作に使うメモリphpdbgだと Zend Memory Manager経由で似た情報を持ったりどちらでカバレッジをとるかで PHPUnitでのメモリ使用量報告が大きく変わるopcacheが SHMとして共有メモリ上に管理するのも別今回のトークではこの辺の話はしない
PHPのメモリ管理機構:リクエスト内でのメモリの確保と解放
メモリの確保は使うときに処理系がこっそりスクリプト内で「メモリ領域を確保」のような関数は(基本的に)ないPHPスクリプトでメモリが必要になったら裏で処理系が ZMMで確保変数を使うとか配列の要素を追加するとかオブジェクトを newするとかスクリプト内のどの部分がどのくらいメモリを必要とするかは意識せず使える逆に言うとふつうには意識することができないmemory_get_usage()を至るところに差し込めば近づけはする
リクエスト内で不要になったメモリ領域は?単純な値を持つローカル変数は VMスタックで管理整数値や浮動小数点数、bool値など関数が終了し次第そのまま破棄してよいオブジェクトや配列、文字列などは参照カウントで順次解放スクリプト内で使われなくなった領域から解放される循環参照は参照カウントでは破棄できないM&Sっぽく可能性ある奴を一通りなめる循環参照 GCがあるhttps://www.php.net/manual/ja/features.gc.collecting-cycles.php
前提: zvalについてPHPの値は zvalという 128bit (= 16byte)の構造体で表現zvalは値の種類を表す型情報と、値そのものを持つ値そのものは unionで表現される 64bit(= 8byte)分long, double, true, false, string, array, object, nullなどなんでも入るstring、array、object、resourceの zvalは詳細データ構造へのポインタが値処理系で内部的に使う一部の特別な値でも利用クラスの定義情報を名前から引くための辞書など
VMスタックローカル変数領域は VMスタック内の zval配列関数呼び出しのたびに VMスタックへ必要なローカル変数や引数用の領域を確保関数が returnするたびにローカル変数の zvalを含むコールフレーム領域を取り除く整数値や浮動小数点数、bool値のような単純な値はこれで十分zvalが詳細データへのポインタを持つ系なら?オブジェクトや配列、文字列など実体は別の領域、zval削除だけではダメfunction f1() {$a = 123; f2();}function f2() {$b = 456; $c = 789; f3();}function f3() {$e = 'abc'; /*今ここを実行中とする*
参照カウントオブジェクトや配列、文字列などの実体は参照カウントを持つ今 N箇所で使われています、の N関数へ渡したり関数から返したり異なる変数へ代入したりするたび 1増える参照が消えるたび 1減るローカル変数が unset()されたりスコープを抜けてスタックから破棄されたりカウント 0になったら解放
循環参照 GC配列とオブジェクトでは循環参照が発生し得る参照カウントでは循環参照を解放できない参照カウントを減らした際 0にならない配列やオブジェクトがあれば容疑者入り処理系は疑わしいものを root bufferと呼ばれるバッファへすべて記録参照カウントが 0になるなどして破棄される際は root bufferから除去root bufferがある程度たまって閾値を超えると循環参照 GCを実行細かい話は y-uti先生の『PHPの GCの話』を見ようhttps://www.slideshare.net/y-uti/php-gc
循環参照 GCの利用上の注意点循環参照 GCのトリガは root bufferの埋まり具合埋まり具合が閾値を超えないと循環参照 GCは実行されないつまり memory_limitを超えても循環参照 GCは実行されない手動で良いタイミングに gc_collect_cycle()を呼ぶだけでmemory_limit越えを避けられる場合も
リクエスト終了まで解放されない領域リクエスト終了まで解放されない領域もあるグローバル変数関数内静的変数クラス static・クラス定数定数コンパイルされたファイルや関数・クラスの情報コンパイラが使うための作業用領域
不意にスクリプトが終了した場合の処理リクエストの処理中に不意にスクリプトが終了した場合は?exit()とかリクエストごとのメモリ領域そのものが解放されるので基本は大丈夫オブジェクトがデストラクタで永続領域の何かを解放したがっているかもEG(objects_store)に全オブジェクトの参照があり、シャットダウン処理で順次破棄PHPマニュアルのデストラクタのページにも少し書いてあるhttps://www.php.net/manual/ja/language.oop5.decon.php#language.oop5.decon.destructorあるいは、スクリプトの終了時にも順不同でコールされます
何がリクエストの中でメモリを使っているか、駆け足でまとめるスクリプトコンパイル時の作業用領域コンパイルされた VM命令列グローバル変数用の zval領域文字列、配列、オブジェクトの実体定数用の zval領域クラスや関数の静的領域VMスタック処理系内の各種管理データ定数や関数・クラスの各種定義情報とかEG(objects_store) とかなどなど
PHPのメモリ使用量の計測事情
計測の必要性:メモリ使用量はサーバ性能につながり得るWebシステムの多くは I/Oバウンドなので CPUが遊びがちサーバ資源を有効活用するのに PHPワーカを増やしたいCPUが余っていても各ワーカプロセスがメモリを食いすぎると並列度を上げづらい実質的に可処分メモリ /ワーカの消費メモリが並列度の限界になる
計測の必要性: PHPツールはメモリを食いがちWeb以外のシーンでも最近の PHPは汎用言語としてわりと使えるGB単位でメモリを食う静的解析ツールの改善などに糸口が見つけられると嬉しい
計測の必要性: long runningへの安心感リクエストごとに状態をリセットしない AltFPMが成熟してきたroadrunner / swooleなどPHPワーカがリクエストをまたいで状態を保持し続ける利用シーンも今後増えていくかも何がどこでメモリを使っているか分からないのは可観測性に問題
計測の必要性:当てずっぽうでは当たらない誰が言ったか「推測するな、計測せよ」処理時間の場合と同様、メモリ使用量もボトルネックが生まれがちごく一部の原因が多くのメモリを消費するある程度以上の規模のシステムで改善点を突き止めるのは当てずっぽうでは難しい砂漠でゴマ粒を、haystackで needleを探すようなもの
計測の必要性はあるが既存の方法は限られているmemory_get_usage() / memory_get_peak_usage()xdebug / tideways_xhprofphp-memprofphp-meminfo
memory_get_usage() /memory_get_peak_usage()memory_get_usage()で現在のメモリ使用量取得memory_get_peak_usage()でリクエスト内での最大使用量取得PHP 8.2以降は memory_reset_peak_usage()で最大使用量記録をリセットできるある処理を行う前後での差分を取ることで、その処理でのメモリ増加量や減少量を計測できるしかし……$before = memory_get_usage();$result =なにかの処理();$diff = memory_get_usage() - $befo
memory_get_usage() /memory_get_peak_usage()の問題点1問題になるケースは何らかの理由でメモリが解放されていないいつ確保されたメモリがいつ解放されているか何が解放されるべき時に解放されていないのか使用量の集計だけでは分からないごく一部のケースにアテがつけられるだけある処理で一気に確保されたメモリが、その後の処理で解放されている場合もあるどこで参照カウントが 0になるかは簡単には分からない場合が多い適切に解放されていれば別に何も問題がなかったりする
memory_get_usage() /memory_get_peak_usage()の問題点2そもそも仕込むのが面倒くさい面倒くさい上に問題点 1のためにリターンが大したことない計測対象のソースコードへ大きな改変が必要な手段は選びづらい
xdebug / xhprof処理時間を計測するプロファイラ機能を持つおまけ機能で関数の出入りの際にメモリ使用量・ピーク使用量を自動で記録できるPECLの xhprofは最近メンテナが変わって復活tideways_xhprofは役割を終えたとして今月(10/6)アーカイブされた本質的には memory_get_usage() / memory_get_peak_usage()方式と同じ減らない理由が分からない、何が減っていないのかが分からないhttps://xdebug.org/https://github.com/longxinH/xhprof
php-memprof現状での良い選択肢の一つPCELの C拡張スクリプトの全関数実行をフックして今どこを実行しているか、を追跡zend_mm_set_custom_handlers()で ZendMMのメモリ確保・解放処理をフックemalloc()や efree()の実装を差し替えどこで確保されたメモリがどこで解放されたか、解放されていないのはどこか、を関数実行単位で追跡可能memory_limit超過時に自動出力する機能もhttps://github.com/arnaud-lb/php-memory-profiler
php-memprofの弱点全関数実行と全メモリ確保・解放処理のフックにはオーバーヘッドがある計測用に本来とは異なる処理が行われ、厳密には挙動が変わる本番環境での問題特定には不向き「どの関数の」確保が解放されていないか、までは分かるが、何の領域かまでは不明処理系への C拡張のインストールが必要
php-meminfoPECLにはないが C拡張meminfo_dump() を提供呼び出し時点のスクリプト内のコールスタックの変数情報を JSONとしてダンプするダンプ内容は PHPスクリプトで解析・集計可能「何が」メモリ食いかをかなり絞り込めるhttps://github.com/BitOne/php-meminfo
php-meminfoの弱点2022年 3月の CI設定の修正を最後に更新されていない循環参照には対応していない定数や関数の静的変数には対応していない処理系への C拡張のインストールが必要スクリプト側を修正してのダンプ出力が必要
PHPで PHPのメモリプロファイラを作ろう
前提:旧作・reliPHPの PHPによる PHPのためのプロファイラ前から PHPスクリプトの処理時間を計測するプロファイラ reliを PHPで作ってる処理系の ELFバイナリと procfsのメモリマップを解析外部プロセスの処理系の重要構造体の仮想アドレスを特定FFIでシステムコールを呼び別プロセスの処理系内のメモリを読むgdbなどのデバッガと同じようなことをやる処理系内部のメモリレイアウトの知識を持って内部情報を解釈実行中の関数・VM命令のコールトレースをサンプリングで取得よく取れるものがボトルネックhttps://github.com/reliforp/reli-prof
処理時間以外の情報も取れるのでは?元ネタの phpspyではその時点のメモリ使用量・最大使用量を取得できるこれは結局 php-memprof以外と同じ問題を持つ元ネタの phpspyは指定したファイル・行のローカル・グローバル変数の値の監視などもできる似たことをもっと大規模にやったら?
EGから読めば読めそうな情報グローバル変数全部入りテーブル定義済み関数全部入りテーブル定義済みクラス全部入りテーブル定義済み定数全部入りテーブルコールスタック内ローカル変数わりと全部取れるのでは?
チャレンジ:稼働中システムへの利用可能性データの取得中はさすがに対象プログラムを停止させたい実行中の VM状態変化の影響は単なるコールトレース以上の筈、読む箇所も多い止めなければ対象の状態を取得している間に状態が変わってしまうFFI経由で ptraceを呼べば止められるしかし長く止めてしまうと本番環境などで使い辛くなる解析処理には時間がかかる
想定する解法:メモリプールのまるごとコピー処理系のメモリプールをほぼ解析なしで一気にコピーして即座に停止解除リクエストごとのプールと永続領域と両方コピー後のデータでじっくり解析力こそパワー
プロセス外からのコピー速度いまどきの DDR4や DDR5の速度ならわりといける?process_vm_readvで別プロセスから 1GB分のメモリをコピってくるのに家のマシンで 234ミリ秒システムコールの処理としてのオーバーヘッドがまあまあある素朴な memcpyなどとは雲泥とはいえよくある memory_limitはこの 1/4や 1/8以下遅いことは遅いが許容できないほどでもない本当に困ったら対象へ C拡張でも突っ込めば memcpyの速度で共有メモリ域へコピー可能
チャレンジ:メモリプールの見つけ方問題外部プロセスのメモリプールのアドレスは自明でないAG (Allocator Globals)は公開シンボルではないので普通にはたどれないデバッグシンボル付の処理系なら取れるが避けたい、使う側がめんどくさいから
リクエストごとのメモリプールの構造ZendMMの管理メモリは 2MBごとのチャンク各チャンク内部で更に 4KBごとのページへ分割emallocは各チャンクを更に小分けにしたり複数ページまとめたりした領域を返す各チャンク領域先頭は zend_mm_chunk構造体zend_mm_chunkは双方向リンクリストで各チャンクをつなげている最初に確保されるチャンクはメインチャンクという特別なチャンクプール全体の管理情報 zend_mm_heapを持つ処理系内アロケータからは AG(mm_heap)としてデータ領域のポインタからアクセス
想定する解法:めちゃくちゃ強引な手で見つける一旦てきとうなリクエスト内確保の要素のメモリアドレスを EG経由で解決/proc//mapsを見て当該 mmap領域を特定2MBのチャンクサイズにアラインしたアドレスを総当たりしつつ、メインチャンクに特有の構造を持つかで同定メインチャンクに固定オフセットにヒープ全体を管理する zend_mm_heap全チャンクは先頭要素にこれへのポインタ全チャンクが通し番号を持ち、メインは 0全チャンクは双方向リストで接続力こそパワー
チャレンジ:循環参照 GCの対象取得循環参照 GCの root bufferは普通にはプロセス外から見えないデバッグシンボル付きの処理系を使えばいけるが避けたいroot bufferが取れないと特定できない領域がある循環参照のために回収されていないどこからも参照されていない領域メインチャンクのようなズルができない
想定する解法:半分諦めるroot bufferが取れなくても EG(objects_store)に全オブジェクトの参照があるこの中にある他から参照をたどれない奴らが循環参照 GCの対象かもしれない候補実際は拡張が持つ固有のデータ構造などが有効な参照を握っている可能性もとにかくオブジェクトの情報取得は漏れなく確実にできる循環参照を起こし得るのはオブジェクトと配列のみオブジェクトをカバーできるだけでかなり有効な筈
チャレンジ:どのタイミングで取得するかphp-memprofならスクリプト終了時にプロファイル結果を吐けるmemory_limit越え時の自動出力も可能外部プロセスからのサンプリング方式ではプロセスを止めるタイミングを選びきれない止めた瞬間が対象プロセスにとってどういうタイミングかが分からないリクエストの開始直後でメモリ状態に何の問題もないタイミングかもしれないメモリ使用量が一旦膨れ上がった後ある程度回収されて平穏なタイミングかもしれない
想定する解法:色々できるようにしてみるmemory_get_usage()相当の情報は zend_mm_heap.size経由で取れるサンプリングで閾値を越えた場合にメモリ領域のダンプを取得、といった対応もできる対象プロセスに手を入れる解法を許容し、自らメモリダンプの取得を依頼できるようにする手も
チャレンジ:どのように結果を出力するかphp-memprofなら実質的に関数実行の性能計測と同様の可視化が可能KCachegrindなどで関数呼び出しのツリーを表示こちらもプロセス全体を根として各種情報をぶら下げたツリーとして扱うことは可能だが……複数の箇所から参照されるデータをどう扱えばよいか
想定する解法: PHPerなら SQLではないかRDBのテーブルをいくつか定義しSQLiteなどに各種情報を突っ込む視点を変えて集計できると使い勝手よさそうクラスごとのメモリ使用量や参照元を絞った解析とかFUSEでファイルシステムとしてサイズ情報をマウント、とかも考えはした参照をハードリンク扱いduや nautilusなどのファイル容量集計などで集計を見れるみたいなわりと真面目に検討したが、SQLの方が PHPerらしいかなと思い直した
実際にある程度作ってみた PoCについてまだ見栄えのする出力がない←のでしょうがなくJSONダンプ(たぶん会場でよく見えない)https://github.com/reliforp/reli-prof/pull/294
できている部分PHP 8.2ターゲット前提の対応zvalのダンプ対象プロセスの各種データのダンプグローバル変数テーブル定義済み関数テーブル定義済みクラステーブル定義済み定数テーブルコールスタック内ローカル変数デバッグシンボルなしでのメインチャンクの特定サイズ情報のある程度の集計
まだの部分丸っとコピーしたメモリダンプからのゆっくり解析EG(objects_store)の対応取得タイミングのがんばりRDBへの処理結果の突っ込みFiber対応参照カウントの取得・循環参照の検知FFIなど各拡張固有のデータ構造の対応複数バージョンの PHP対応ZTS対応
うまいこといくと手に入りそうなツール対象プログラム無修正で使えるプロセス外からスクリプト内のほぼ全ての状態を取得し SQLでクエリ可能とできる集計によりメモリリークやメモリボトルネックを特定できるちょっとしたデバッガのかわりにも使える方向性は php-meminfoに近いが、PHP製で PHPerが修正可能
まとめ
メモリは限られた大事な資源
PHPの実行時情報は基本リクエストごとのメモリ領域に
php-memprofは現在あるメモリ使用量計測の選択肢では有力
根性があれば PHPでも力業によりメモリプロファイラは作れる(たぶん)
おしまい