Slide 1

Slide 1 text

PHP で PHP のメモリプロファイラを作ろう 五十嵐 進士 / sji / sj-i / @sji_ch  

Slide 2

Slide 2 text

自己紹介 @sji_ch SNS 上のアイコンは GitHub が自動生成した奴

Slide 3

Slide 3 text

生まれも育ちも仙台

Slide 4

Slide 4 text

PHP カンファレンス仙台とかやった

Slide 5

Slide 5 text

ふつうのサラリーマン 株式会社インフィニットループ仙台支社所属 スマホゲーのサーバサイドプログラマ 地元が仙台や札幌の人とかはぜひ一緒に働きま しょう

Slide 6

Slide 6 text

Agenda メモリとはなんぞや(約 10 分) PHP のメモリ管理機構について(約 10 分) PHP のメモリ消費量の計測方法(約 10 分) PHP のメモリプロファイラを自作する取り組み(約 15 分)

Slide 7

Slide 7 text

メモリの概要

Slide 8

Slide 8 text

メモリはコンピュータの部品 コンピュータは様々な部品から構成 メモリはその中の一つ

Slide 9

Slide 9 text

コンピュータは数値で動く 2 つの状態を持つものの並び = 信号 = 数値 電気の強弱 磁性体の向き 0 と 1 この信号パターンならこう動く、というのが機械への命令 命令を動作の種類と内容を分けて構成すれば様々な情報が数値に

Slide 10

Slide 10 text

CPU は鳥頭 CPU が命令を処理 CPU 自体では多くの命令を覚えられない CPU が命令を読み取るための装置が必要

Slide 11

Slide 11 text

メモリが数値の並び(= 情報)を大量に記憶 メモリが CPU のために情報を記憶 CPU はメモリから情報を読んでメモリに情報を書き込む

Slide 12

Slide 12 text

扱うデータの単位 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 メガバイト

Slide 13

Slide 13 text

メモリはバイト単位のデータの並び メモリはバイトの情報をおさめた箱の並び 一つ一つの箱に背番号 = アドレス、番地 ある番地や番地の範囲をメモリ領域と呼ぶ

Slide 14

Slide 14 text

メモリは限られた資源 メモリは CPU に大量に読み書きされる メモリが遅いほど CPU が待たされることに メモリは比較的高速に動作するが、高価で大容量化が大変 ついでに通電しなくなると記録していた情報が消える ストレージと組み合わせてやりくり 遅くても安く大容量化でき、通電しなくても情報が消えない

Slide 15

Slide 15 text

メモリは OS が管理 コンピュータ上では複数プログラムが同時に動く マルチプロセス OS がプロセス間でハードウェア資源の利用を仲介 メモリも OS が管理 プロセスは OS にメモリを要求 OS はプロセス間を仮想メモリ空間で隔離しながらメモリを割り当て プロセスは自分だけのメモリ領域を使っているように見える ストレージとも組み合わせてやりくり(スワップ)

Slide 16

Slide 16 text

それでも無い袖は振れない ストレージはメモリと比べて圧倒的に遅い スワップが発生すると CPU が全然パワーを発揮できない 設定でスワップを切ったり制限した状態で動かすこともよくある メモリが本当に足りなくなると OOM Killer にプロセスを殺されたりする 各プログラムでなるべく無断のないメモリの使い方が必要

Slide 17

Slide 17 text

PHP のメモリ管理機構: memory_limit あたり

Slide 18

Slide 18 text

Allowed memory size of 134217728 bytes exhausted (tried to allocate 4096 bytes)

Slide 19

Slide 19 text

memory_limit とは リクエストごとのメモリ上限を指定する ini 項目 安全装置 PHP はマルチプロセスでリクエストを処理 マシンリソースを食いつぶさないよう制限できる リクエストの処理が memory_limit を越えたらプロセスが自刃する 最近のデフォルト値は 128MB プロジェクトや利用箇所によって違う設定にしてるはず

Slide 20

Slide 20 text

ZendMemoryManager PHP 処理系のメモリ管理部品 2 種類のメモリ領域 リクエストごとのメモリ領域 処理系や C 拡張は libc の malloc や free を パクった emalloc や efree などの API で操作 永続メモリ領域 通常 libc の malloc とか free が使われる どちらも最終的には OS からもらう 仮想メモリ領域を使う 名前のZend はZeev とAndi の名前から

Slide 21

Slide 21 text

基本はリクエストごとのメモリ領域に入る PHP スクリプトのメモリ状態は通常リクエストごとにリセット 大部分のデータをリクエストごとのメモリ領域で管理 リークの心配があまりなくなる memory_limit での制限対象はこっちのメモリ領域 memory_get_usage() などでとる情報もこの部分

Slide 22

Slide 22 text

memory_limit の制限の対象外 永続メモリ領域や Zend Memory Manager 管理外の領域 は memory_limit の対象外 たとえば Xdebug が動作に使うメモリ phpdbg だと Zend Memory Manager 経由で似た情報を持ったり どちらでカバレッジをとるかで PHPUnit でのメモリ使用量報告が大きく変わる opcache が SHM として共有メモリ上に管理するのも別 今回のトークではこの辺の話はしない

Slide 23

Slide 23 text

PHP のメモリ管理機構: リクエスト内でのメモリの確保と解放

Slide 24

Slide 24 text

メモリの確保は使うときに処理系がこっそり スクリプト内で「メモリ領域を確保」のような関数は(基本的に)ない PHP スクリプトでメモリが必要になったら裏で処理系が ZMM で確保 変数を使うとか 配列の要素を追加するとか オブジェクトを new するとか スクリプト内のどの部分がどのくらいメモリを必要とするかは意識せず使える 逆に言うとふつうには意識することができない memory_get_usage() を至るところに差し込めば近づけはする

Slide 25

Slide 25 text

リクエスト内で不要になったメモリ領域は? 単純な値を持つローカル変数は VM スタックで管理 整数値や浮動小数点数、bool 値など 関数が終了し次第そのまま破棄してよい オブジェクトや配列、文字列などは参照カウントで順次解放 スクリプト内で使われなくなった領域から解放される 循環参照は参照カウントでは破棄できない M&S っぽく可能性ある奴を一通りなめる循環参照 GC がある https://www.php.net/manual/ja/features.gc.collecting-cycles.php

Slide 26

Slide 26 text

前提: zval について PHP の値は zval という 128bit (= 16byte) の構造体で表現 zval は値の種類を表す型情報と、値そのものを持つ 値そのものは union で表現される 64bit (= 8byte )分 long, double, true, false, string, array, object, null などなんでも入る string 、array 、object 、resource の zval は詳細データ構造へのポインタが値 処理系で内部的に使う一部の特別な値でも利用 クラスの定義情報を名前から引くための辞書など

Slide 27

Slide 27 text

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'; /* 今ここを実行中とする *

Slide 28

Slide 28 text

参照カウント オブジェクトや配列、文字列などの実体は参照カウントを持つ 今 N 箇所で使われています、の N 関数へ渡したり関数から返したり異なる変数へ代入したりするたび 1 増える 参照が消えるたび 1 減る ローカル変数が unset() されたり スコープを抜けてスタックから破棄されたり カウント 0 になったら解放

Slide 29

Slide 29 text

循環参照 GC 配列とオブジェクトでは循環参照が発生し得る 参照カウントでは循環参照を解放できない 参照カウントを減らした際 0 にならない配列やオブジェクトがあれば容疑者入り 処理系は疑わしいものを root buffer と呼ばれるバッファへすべて記録 参照カウントが 0 になるなどして破棄される際は root buffer から除去 root buffer がある程度たまって閾値を超えると循環参照 GC を実行 細かい話は y-uti 先生の『PHP の GC の話』を見よう https://www.slideshare.net/y-uti/php-gc

Slide 30

Slide 30 text

循環参照 GC の利用上の注意点 循環参照 GC のトリガは root buffer の埋まり具合 埋まり具合が閾値を超えないと循環参照 GC は実行されない つまり memory_limit を超えても循環参照 GC は実行されない 手動で良いタイミングに gc_collect_cycle() を呼ぶだけで memory_limit 越えを避けられる場合も

Slide 31

Slide 31 text

リクエスト終了まで解放されない領域 リクエスト終了まで解放されない領域もある グローバル変数 関数内静的変数 クラス static ・クラス定数 定数 コンパイルされたファイルや関数・クラスの情報 コンパイラが使うための作業用領域

Slide 32

Slide 32 text

不意にスクリプトが終了した場合の処理 リクエストの処理中に不意にスクリプトが終了した場合は? exit() とか リクエストごとのメモリ領域そのものが解放されるので基本は大丈夫 オブジェクトがデストラクタで永続領域の何かを解放したがっているかも EG(objects_store) に全オブジェクトの参照があり、シャットダウン処理で順次破棄 PHP マニュアルのデストラクタのページにも少し書いてある https://www.php.net/manual/ja/language.oop5.decon.php#language.oop5.decon.destructor あるいは、スクリプトの終了時にも順不同でコールされます

Slide 33

Slide 33 text

何がリクエストの中でメモリを使っているか、 駆け足でまとめる スクリプトコンパイル時の作業用領域 コンパイルされた VM 命令列 グローバル変数用の zval 領域 文字列、配列、オブジェクトの実体 定数用の zval 領域 クラスや関数の静的領域 VM スタック 処理系内の各種管理データ 定数や関数・クラスの各種定義情報とか EG(objects_store ) とか などなど

Slide 34

Slide 34 text

PHP のメモリ使用量の計測事情

Slide 35

Slide 35 text

計測の必要性: メモリ使用量はサーバ性能につながり得る Web システムの多くは I/O バウンドなので CPU が遊びがち サーバ資源を有効活用するのに PHP ワーカを増やしたい CPU が余っていても各ワーカプロセスがメモリを食いすぎると並列度を上げづらい 実質的に可処分メモリ / ワーカの消費メモリが並列度の限界になる

Slide 36

Slide 36 text

計測の必要性: PHP ツールはメモリを食いがち Web 以外のシーンでも最近の PHP は汎用言語としてわりと使える GB 単位でメモリを食う静的解析ツールの改善などに糸口が見つけられると嬉しい

Slide 37

Slide 37 text

計測の必要性: long running への安心感 リクエストごとに状態をリセットしない AltFPM が成熟してきた roadrunner / swoole など PHP ワーカがリクエストをまたいで状態を保持し続ける利用シーンも今後増えていくかも 何がどこでメモリを使っているか分からないのは可観測性に問題

Slide 38

Slide 38 text

計測の必要性: 当てずっぽうでは当たらない 誰が言ったか「推測するな、計測せよ」 処理時間の場合と同様、メモリ使用量もボトル ネックが生まれがち ごく一部の原因が多くのメモリを消費する ある程度以上の規模のシステムで改善点を突き 止めるのは当てずっぽうでは難しい 砂漠でゴマ粒を、haystack で needle を探すよ うなもの

Slide 39

Slide 39 text

計測の必要性はあるが既存の方法は限られている memory_get_usage() / memory_get_peak_usage() xdebug / tideways_xhprof php-memprof php-meminfo

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

memory_get_usage() / memory_get_peak_usage() の問題点1 問題になるケースは何らかの理由でメモリが解放されていない いつ確保されたメモリがいつ解放されているか 何が解放されるべき時に解放されていないのか 使用量の集計だけでは分からない ごく一部のケースにアテがつけられるだけ ある処理で一気に確保されたメモリが、その後の処理で解放されている場合もある どこで参照カウントが 0 になるかは簡単には分からない場合が多い 適切に解放されていれば別に何も問題がなかったりする

Slide 42

Slide 42 text

memory_get_usage() / memory_get_peak_usage() の問題点2 そもそも仕込むのが面倒くさい 面倒くさい上に問題点 1 のためにリターンが大したことない 計測対象のソースコードへ大きな改変が必要な手段は選びづらい

Slide 43

Slide 43 text

xdebug / xhprof 処理時間を計測するプロファイラ機能を持つ おまけ機能で関数の出入りの際にメモリ使用量・ピーク使用量を自動で記録できる PECL の xhprof は最近メンテナが変わって復活 tideways_xhprof は役割を終えたとして今月(10/6 )アーカイブされた 本質的には memory_get_usage() / memory_get_peak_usage() 方式と同じ 減らない理由が分からない、何が減っていないのかが分からない https://xdebug.org/ https://github.com/longxinH/xhprof

Slide 44

Slide 44 text

php-memprof 現状での良い選択肢の一つ PCEL の C 拡張 スクリプトの全関数実行をフックして今どこを 実行しているか、を追跡 zend_mm_set_custom_handlers() で ZendMM の メモリ確保・解放処理をフック emalloc() や efree() の実装を差し替え どこで確保されたメモリがどこで解放された か、解放されていないのはどこか、を関数実 行単位で追跡可能 memory_limit 超過時に自動出力する機能も https://github.com/arnaud-lb/php-memory- profiler

Slide 45

Slide 45 text

php-memprof の弱点 全関数実行と全メモリ確保・解放処理のフックにはオーバーヘッドがある 計測用に本来とは異なる処理が行われ、厳密には挙動が変わる 本番環境での問題特定には不向き 「どの関数の」確保が解放されていないか、までは分かるが、何の領域かまでは不明 処理系への C 拡張のインストールが必要

Slide 46

Slide 46 text

php-meminfo PECL にはないが C 拡張 meminfo_dump() を提供 呼び出し時点のスクリプト内のコールスタッ クの変数情報を JSON としてダンプする ダンプ内容は PHP スクリプトで解析・集計可能 「何が」メモリ食いかをかなり絞り込める https://github.com/BitOne/php-meminfo

Slide 47

Slide 47 text

php-meminfo の弱点 2022 年 3 月の CI 設定の修正を最後に更新されていない 循環参照には対応していない 定数や関数の静的変数には対応していない 処理系への C 拡張のインストールが必要 スクリプト側を修正してのダンプ出力が必要

Slide 48

Slide 48 text

PHP で PHP のメモリプロファイラを作ろう

Slide 49

Slide 49 text

前提: 旧作・reli PHP の PHP による PHP のためのプロファイラ 前から PHP スクリプトの処理時間を計測するプロファイラ reli を PHP で作ってる 処理系の ELF バイナリと procfs のメモリマップを解析 外部プロセスの処理系の重要構造体の仮想アドレスを特定 FFI でシステムコールを呼び別プロセスの処理系内のメモリを読む gdb などのデバッガと同じようなことをやる 処理系内部のメモリレイアウトの知識を持って内部情報を解釈 実行中の関数・VM 命令のコールトレースをサンプリングで取得 よく取れるものがボトルネック https://github.com/reliforp/reli-prof

Slide 50

Slide 50 text

処理時間以外の情報も取れるのでは? 元ネタの phpspy ではその時点のメモリ使用量・最大使用量を取得できる これは結局 php-memprof 以外と同じ問題を持つ 元ネタの phpspy は指定したファイル・行の ローカル・グローバル変数の値の監視などもできる 似たことをもっと大規模にやったら?

Slide 51

Slide 51 text

EG から読めば読めそうな情報 グローバル変数全部入りテーブル 定義済み関数全部入りテーブル 定義済みクラス全部入りテーブル 定義済み定数全部入りテーブル コールスタック内ローカル変数 わりと全部取れるのでは?

Slide 52

Slide 52 text

チャレンジ: 稼働中システムへの利用可能性 データの取得中はさすがに対象プログラムを停止させたい 実行中の VM 状態変化の影響は単なるコールトレース以上の筈、読む箇所も多い 止めなければ対象の状態を取得している間に状態が変わってしまう FFI 経由で ptrace を呼べば止められる しかし長く止めてしまうと本番環境などで使い辛くなる 解析処理には時間がかかる

Slide 53

Slide 53 text

想定する解法: メモリプールのまるごとコピー 処理系のメモリプールをほぼ解析なしで一気にコピーして即座に停止解除 リクエストごとのプールと永続領域と両方 コピー後のデータでじっくり解析 力こそパワー

Slide 54

Slide 54 text

プロセス外からのコピー速度 いまどきの DDR4 や DDR5 の速度ならわりといける? process_vm_readv で別プロセスから 1GB 分のメモリをコピってくるのに家のマシンで 234 ミリ秒 システムコールの処理としてのオーバーヘッドがまあまあある 素朴な memcpy などとは雲泥 とはいえよくある memory_limit はこの 1/4 や 1/8 以下 遅いことは遅いが許容できないほどでもない 本当に困ったら対象へ C 拡張でも突っ込めば memcpy の速度で共有メモリ域へコピー可能

Slide 55

Slide 55 text

チャレンジ: メモリプールの見つけ方問題 外部プロセスのメモリプールのアドレスは自明でない AG (Allocator Globals) は公開シンボルではないので普通にはたどれない デバッグシンボル付の処理系なら取れるが避けたい、使う側がめんどくさいから

Slide 56

Slide 56 text

リクエストごとのメモリプールの構造 ZendMM の管理メモリは 2MB ごとのチャンク 各チャンク内部で更に 4KB ごとのページへ分割 emalloc は各チャンクを更に小分けにしたり複数 ページまとめたりした領域を返す 各チャンク領域先頭は zend_mm_chunk 構造体 zend_mm_chunk は双方向リンクリストで各チャ ンクをつなげている 最初に確保されるチャンクはメインチャンクと いう特別なチャンク プール全体の管理情報 zend_mm_heap を持つ 処理系内アロケータからは AG(mm_heap) とし てデータ領域のポインタからアクセス

Slide 57

Slide 57 text

想定する解法: めちゃくちゃ強引な手で見つける 一旦てきとうなリクエスト内確保の要素のメモ リアドレスを EG 経由で解決 /proc//maps を見て当該 mmap 領域を特定 2MB のチャンクサイズにアラインしたアドレ スを総当たりしつつ、メインチャンクに特有 の構造を持つかで同定 メインチャンクに固定オフセットにヒープ 全体を管理する zend_mm_heap 全チャンクは先頭要素にこれへのポインタ 全チャンクが通し番号を持ち、メインは 0 全チャンクは双方向リストで接続 力こそパワー

Slide 58

Slide 58 text

チャレンジ: 循環参照 GC の対象取得 循環参照 GC の root buffer は普通にはプロセス外から見えない デバッグシンボル付きの処理系を使えばいけるが避けたい root buffer が取れないと特定できない領域がある 循環参照のために回収されていないどこからも参照されていない領域 メインチャンクのようなズルができない

Slide 59

Slide 59 text

想定する解法: 半分諦める root buffer が取れなくても EG(objects_store) に全オブジェクトの参照がある この中にある他から参照をたどれない奴らが循環参照 GC の対象かもしれない候補 実際は拡張が持つ固有のデータ構造などが有効な参照を握っている可能性も とにかくオブジェクトの情報取得は漏れなく確実にできる 循環参照を起こし得るのはオブジェクトと配列のみ オブジェクトをカバーできるだけでかなり有効な筈

Slide 60

Slide 60 text

チャレンジ: どのタイミングで取得するか php-memprof ならスクリプト終了時にプロファイル結果を吐ける memory_limit 越え時の自動出力も可能 外部プロセスからのサンプリング方式ではプロセスを止めるタイミングを選びきれない 止めた瞬間が対象プロセスにとってどういうタイミングかが分からない リクエストの開始直後でメモリ状態に何の問題もないタイミングかもしれない メモリ使用量が一旦膨れ上がった後ある程度回収されて平穏なタイミングかもしれない

Slide 61

Slide 61 text

想定する解法: 色々できるようにしてみる memory_get_usage() 相当の情報は zend_mm_heap.size 経由で取れる サンプリングで閾値を越えた場合にメモリ領域のダンプを取得、といった対応もできる 対象プロセスに手を入れる解法を許容し、自らメモリダンプの取得を依頼できるようにする手も

Slide 62

Slide 62 text

チャレンジ: どのように結果を出力するか php-memprof なら実質的に関数実行の性能計測と同様の可視化が可能 KCachegrind などで関数呼び出しのツリーを表示 こちらもプロセス全体を根として各種情報をぶら下げたツリーとして扱うことは可能だが…… 複数の箇所から参照されるデータをどう扱えばよいか

Slide 63

Slide 63 text

想定する解法: PHPer なら SQL ではないか RDB のテーブルをいくつか定義し SQLite などに各種情報を突っ込む 視点を変えて集計できると使い勝手よさそう クラスごとのメモリ使用量や参照元を絞った解析とか FUSE でファイルシステムとしてサイズ情報をマウント、とかも考えはした 参照をハードリンク扱い du や nautilus などのファイル容量集計などで集計を見れるみたいな わりと真面目に検討したが、SQL の方が PHPer らしいかなと思い直した

Slide 64

Slide 64 text

実際にある程度作ってみた PoC について まだ見栄えのする出力がない ←のでしょうがなくJSON ダンプ (たぶん会場でよく見えない) https://github.com/reliforp/reli-prof/pull/294

Slide 65

Slide 65 text

できている部分 PHP 8.2 ターゲット前提の対応 zval のダンプ 対象プロセスの各種データのダンプ グローバル変数テーブル 定義済み関数テーブル 定義済みクラステーブル 定義済み定数テーブル コールスタック内ローカル変数 デバッグシンボルなしでのメインチャンクの特定 サイズ情報のある程度の集計

Slide 66

Slide 66 text

まだの部分 丸っとコピーしたメモリダンプからのゆっくり解析 EG(objects_store) の対応 取得タイミングのがんばり RDB への処理結果の突っ込み Fiber 対応 参照カウントの取得・循環参照の検知 FFI など各拡張固有のデータ構造の対応 複数バージョンの PHP 対応 ZTS 対応

Slide 67

Slide 67 text

うまいこといくと手に入りそうなツール 対象プログラム無修正で使える プロセス外からスクリプト内のほぼ全ての状態を取得し SQL でクエリ可能とできる 集計によりメモリリークやメモリボトルネックを特定できる ちょっとしたデバッガのかわりにも使える 方向性は php-meminfo に近いが、PHP 製で PHPer が修正可能

Slide 68

Slide 68 text

まとめ

Slide 69

Slide 69 text

メモリは限られた大事な資源

Slide 70

Slide 70 text

PHP の実行時情報は基本リクエストごとのメモリ領域に

Slide 71

Slide 71 text

php-memprof は 現在あるメモリ使用量計測の選択肢では有力

Slide 72

Slide 72 text

根性があれば PHP でも力業により メモリプロファイラは作れる(たぶん)

Slide 73

Slide 73 text

おしまい