Upgrade to Pro — share decks privately, control downloads, hide ads and more …

時間を気にせず普通にカンニングもしつつ ISUCON12 本選問題を PHP でやってみる

sji
March 24, 2023

時間を気にせず普通にカンニングもしつつ ISUCON12 本選問題を PHP でやってみる

sji

March 24, 2023
Tweet

More Decks by sji

Other Decks in Programming

Transcript

  1. 時間を気にせず普通にカンニングもしつつ
    ISUCON12
    本選問題を
    PHP
    でやってみる
    五十嵐 進士 / sji / sj-i / @sji_ch

    View full-size slide

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

    View full-size slide

  3. 生まれも育ちも仙台

    View full-size slide

  4. PHP
    カンファレンス仙台とかやった
    けどもう 4
    年も前

    View full-size slide

  5. ふつうのサラリーマン
    株式会社インフィニットループ仙台支社所属
    スマホゲーのサーバサイドプログラマ

    View full-size slide

  6. 三歳児の父
    かわいい
    絵本好き
    だいぶ人間らしくなってきた

    View full-size slide

  7. WEB+DB PRESS
    の現 PHP
    連載担当
    2021
    年 6
    月から WEB+DB PRESS
    の PHP
    連載
    だいたいわりと真面目な話をしてる
    今回のトークが気になるような人には vol.128

    129
    、131
    あたりオススメ

    View full-size slide

  8. Agenda
    ISUCON
    について
    PHP
    でやってみた記録
    得られた知見まとめ

    View full-size slide

  9. ISUCON
    について

    View full-size slide

  10. ISUCON
    とは
    Iikanjini Speed Up Contest
    の略
    Web
    アプリケーションの性能改善大会
    各種の言語で同内容の処理をする参考実装が与えられる
    ベンチマーカが負荷をかけ、処理性能でスコアを出す
    3
    人までのチームで参加し、サーバを数台与えられる
    レギュレーション内で性能改善
    コード修正からミドルウェア・OS
    変更までわりと何でもアリ
    「ISUCON
    」は、LINE
    株式会社の商標または登録商標です。

    View full-size slide

  11. ISUCON12
    本選
    ISU CONQUEST
    という放置ゲーのサーバ
    時間で椅子を生産
    椅子を売ってコインを得る
    コインでガチャを回す
    ガチャで装備を強化(生産力アップ)

    View full-size slide

  12. ISUCON12
    本選
    仕事だ!舞台設定が完全に仕事(スマホゲー)だ!
    でも PHP
    の本選進出者がいない!
    30
    組のうち 26
    組が Go
    他は Node
    、Ruby
    、Rust
    、Perl
    で 1
    組ずつ
    PHP
    の参考実装は用意されている
    @okashoi
    さんの仕事
    やるしかない

    View full-size slide

  13. 今回の挑戦方針について
    ISUCON12
    本選の優勝者は NaruseJun
    というチーム
    Go
    利用
    341,258

    本選と同等の環境で優勝者と同等のスコアを目指す
    優勝者たちのミドルウェア設定や取り組みをパクリまくる
    ただし PHP
    で!

    View full-size slide

  14. 挑戦しない部分
    時間は気にしない
    本選は 10:00-18:00
    の 8h
    ある程度時間のかかる取り組みでもやる
    再起動試験対策は気にしない
    細かい安定性や正確性も気にしない
    ベンチマーカーのスコアだけ追う
    PHP
    の限界へ近づくのに大事でない部分は割り切る

    View full-size slide

  15. 実行環境
    本選
    AMD EPYC 7763

    アプリ: CPU 2
    コア メモリ 4 GB * 5
    ベンチマーカー: CPU 4
    コア メモリ 8 GB * 1
    今回
    AWS EC2 c6a (AMD EPYC 7R13)
    c6a.large * 5
    c6a.xlarge * 1
    ストレージは EBS gp2
    優勝リポジトリをデプロイすると 343,449
    とか 349,547
    とか
    少し高いが本選スコア 341,258
    とだいぶ似た数字

    View full-size slide

  16. PHP
    でやってみた記録

    View full-size slide

  17. 俺のプロファイラ
    自作の PHP
    プロファイラで随時計測しながら進める
    慣れてるので楽
    重いリクエストも重いクエリも見れる
    いざという時 VM
    命令単体まで絞り込める
    https://github.com/reliforp/reli-prof

    View full-size slide

  18. 初期スコア: 644
    他言語を試してもどれも大体同等の初期スコア
    xdebug
    無効化でもほぼ変わらない
    ボトルネックがほぼ完全に DB

    mysql
    や nginx
    設定は優勝リポジトリを初手でパクった状態
    この時点で本来の本選初期状態よりは改善されてる筈
    デプロイの構成もパクり、各ホスト名を s1
    〜 s5
    に変更
    基本的に優勝リポジトリのコミットログを真似して進める

    View full-size slide

  19. DB
    のインデックス追加: 24439
    user_present_all_received_history
    のインデックス追加
    これだけでめっちゃ伸びる
    DB
    設定パクって開始した影響が大きそう

    View full-size slide

  20. プロファイルをとってみる
    generateID
    がボトルネック
    ユニーク ID
    の生成処理
    DB
    でわけのわからんことをして ID
    を生成して
    いる
    UPDATE id_generator SET
    id=LAST_INSERT_ID(id+1)
    成功するまでリトライ

    View full-size slide

  21. Snowflake ID
    導入: 30858
    ユニーク ID
    の生成は DB
    通さないほうがよい
    衝突しづらい DB
    に優しい値を Web
    側で生成
    ULID
    や Snowflake
    など
    composer
    から Snowflake ID
    の生成器をインストール
    Go
    の優勝チーム記録(24498)
    より順調に伸びて幸先が良い
    計測・ログ系をつけてないのが大きそう
    $ composer require godruoyi/php-snowflake

    View full-size slide

  22. receivePresent N+1
    修正: 33922
    ↓延々コレ系
    愚直にコード書き換え
    生の PDO
    でやるのプレースホルダ的にめん
    どい
    ヘルパを作るとよい
    Go
    のコードは sqlx
    なので簡単そう
    配列渡したらプレースホルダ作ってくれ

    foreach ($list as $item) {
    $sql = 'SELECT * FROM tbl WHERE id=?';
    $stmt = $this->db->prepare($sql);
    $stmt->bindValue(1, $item->id, PDO::PARAM_INT);
    $stmt->execute();
    }
    $placeholders = implode(
    ',',
    array_fill(0, count($list), '?')
    );
    $sql = "SELECT * FROM tbl WHERE id IN ({$placeholder
    $stmt = $this->db->prepare($sql);
    $pos = 1;
    foreach ($list as $item) {
    $stmt->bindValue($pos++, $item->id, PDO::PARAM_I
    }
    $stmt->execute();

    View full-size slide

  23. DB
    ホスト分離: 45687
    fpm
    のプール設定で clear_env=no
    env
    から環境変数で DB
    ホストを渡す
    DB
    アクセスがネットワーク越しになりレイテンシが増える
    優勝チームはこの時点で 40,478

    View full-size slide

  24. (おまけ)
    PDO::ATTR_EMULATE_PREPARES
    を切る:
    36897
    優勝チームは Go
    なので interpolateParams
    を指定
    PDO
    では PDO::ATTR_EMULATE_PREPARES
    がデフォルト on
    で対策不要
    一応切ってみるとわりと効果があるのがわかる
    プロファイルをとるとやはり prepare + execute
    の時間が大きい

    View full-size slide

  25. インデックス修正: 48371
    user_one_time_tokens
    のインデックス修正
    遅めのクエリではあった
    本当はこの段階でアイテム獲得処理が一番重い
    インデックス付け楽なのでシュッとやってた?
    優勝チームは 3
    人なので分業の結果っぽい

    View full-size slide

  26. バルクアップデート化: 52373
    user_presents
    のバルクアップデート化
    優勝チームはこの時点で 49,602

    この直後に計測系・ログ系を外して 53,037
    点、特別賞
    こちらは初手でログを外してるのでほぼ横並び

    View full-size slide

  27. インデックス修正: 61256
    user_presents
    と item_masters, user_sessions
    の index
    追加
    変わらずアイテム獲得処理がボトルネック

    View full-size slide

  28. アイテム獲得処理から coin
    切り出し: 65194
    バルクアップデート化でクエリ削減

    View full-size slide

  29. アイテム獲得処理から cards
    切り出し: 66019
    バルクアップデート化でクエリ削減

    View full-size slide

  30. draw Gacha
    の N+1
    修正: 66884

    View full-size slide

  31. デッキ初期化の N+1
    をなくす: 67607

    View full-size slide

  32. アイテム獲得処理から残りの切り出し: 75336
    一番重かったアイテム獲得処理が改善
    ボトルネックが変わる
    PDO
    接続や Ban / Session
    、マスタ取得
    この時点で優勝リポジトリは 89000
    点ほど
    処理系側へ負荷が移ってきたため

    View full-size slide

  33. マスタ参照でのキャッシュ利用: 80022
    symfony/cache
    で対応
    PhpFilesAdapter
    で opcache
    のキャッシュ利用
    参考: PHPerKaigi 2021
    でPHP
    の不変配列が高速かつ省メモリだという話をしました
    大粒のボトルネックが消えてきた
    そろそろシャーディングを入れる段階
    優勝チームはマスタのキャッシュ利用とほぼ同時期に入れてる
    https://hnw.hatenablog.com/entry/2021/03/29/011242

    View full-size slide

  34. シャーディング DB 2
    台: 74640
    !?
    のびねえ!
    ユーザ ID
    の剰余で接続先を振り分ける単純な実装

    View full-size slide

  35. シャーディング DB 4
    台:
    全然変わらず
    優勝チームはこの時点で 21
    万点出してる
    この時点の構成は以下
    s1
    に nginx + fpm
    s2
    〜 s5
    に mysql
    JIT
    有効にしても特に伸びず
    プロファイルを見てみる

    View full-size slide

  36. PDO
    の生成コスト
    PDO
    の new
    がめちゃくちゃ嵩んでる
    当初 persistent
    が効いてないのか疑う
    が、外すとちゃんとスコアが 5
    万点台へ落ち

    persistent
    有効でも PDO
    の生成が遅い
    リクエストごとの生成を避けるには?

    View full-size slide

  37. RoadRunner: 131353
    SpiralScout
    の AltFPM
    Go
    製の HTTP
    サーバがリクエストを受ける
    通信待ちで無限ループする PHP CLI
    のワーカと
    パイプ通信
    リクエスト間で情報を持ち越せる
    PDO
    インスタンスを使い回せる
    フレームワークの起動コストも消せる
    調べつつハマりつつ 3h
    くらいで移行
    Slim
    からの移行を素振りすると良さそう

    View full-size slide

  38. Ban
    キャッシュ: 152704
    php-di
    で持つ singleton
    オブジェクトのプロパティに配列を突っ込むだけ
    RoadRunner
    導入によりワーカプロセス内で生きるキャッシュになる

    View full-size slide

  39. JIT
    再チャレンジ: 166629
    Go
    側とのスコア差から処理系性能の差が出てきたと判断
    実際スコアが伸びた

    View full-size slide

  40. checkViewer
    キャッシュ +
    ワーカ数調整: 180356
    ユーザの端末 ID
    をオンメモリキャッシュ
    RoadRunner
    のワーカ数を 20

    増やしたり減らしたり試した結果
    あまり I/O
    バウンドでない状況を示してる

    View full-size slide

  41. シャードキーの決定方法修正: 185120
    Snowflake ID
    の下一桁はプロセスごとのカウンタ
    複数プロセスが同タイミングに同じ剰余値を発行しやすい
    同じDB
    への更新が重なりやすくなるため算出方法を変更

    View full-size slide

  42. Ban
    とセッションの redis
    利用: 183684
    RoadRunner
    のワーカは別個に起動されるただの CLI
    プロセス
    opcache
    の SHM
    が共有されない
    プロセス間で共有できるメモリキャッシュを置きたい
    Redis
    を igbinary
    付きで導入
    接続は Unix domain socket
    DB
    アクセスを削れるがスコアが伸びない
    なお RoadRunner
    の kv plugin
    も試したが遅くなる

    View full-size slide

  43. CPU
    がサチった
    vmstat
    はアイドル(id)
    時間の消滅を示す
    user(us)
    と system(sy)
    両方で食ってる
    Redis
    に回す CPU
    が余ってないので伸びない
    激しいコンテキストスイッチ(cs)
    と割り込み(in)
    ---system-- ---cpu---
    in cs us sy id
    42871 39012 60 38 2
    42980 41089 65 32 3
    43729 40602 65 33 2

    View full-size slide

  44. 絶望的な perf stat
    の内訳
    perf stat
    を見るとコンテキストスイッチと処理
    系自体が重そう
    finish_task_switch.isra.0
    __lock_text_start
    __softirqentry_text_start
    _emalloc
    zend_hash_find
    zend_hash_find_known_hash
    zend_array_destroy
    execute_ex
    _efree

    View full-size slide

  45. どうする?わりと困った
    一面では RoadRunner
    の実行モデル起因の限界
    Go
    サーバとのパイプ通信に時間食ってそう
    それでもパイプは IPC
    の中では軽い……
    一面では PHP
    処理系の限界
    プロファイルでもわりと上のほうに VM
    命令
    単体が出てくる状況
    利用元もスクリプトの特定行というより全
    体に散在するものが多い
    処理系は内部処理で PHP
    の配列と同じデータ
    構造をよく使う
    内部の配列操作自体をスクリプトから高速化
    する手段はない

    View full-size slide

  46. 一応 Swoole
    も試した: 147015
    一応は試した、が、ダメ
    PHP
    処理の部分がボトルネックという前提が変わらない
    HTTP
    サーバ部分がワーカプロセスとパイプ通信する形態も同じ
    I/O
    をより効率的に行うために機構が複雑?
    CPU
    効率ではむしろオーバヘッドが大きそう
    CPU
    余ってると多分 Swoole
    のがいい?
    何か下手をうって同期 I/O
    が混ざった可能性はある
    が、計測を見る限りたぶん改善しても大きくは伸びない

    View full-size slide

  47. 状況整理
    もう余ってる CPU
    リソースがない
    優勝チームの構成をなぞって Web 1
    台 DB 4
    台、なら
    DB
    サーバのリソースは余ってる
    余ってる(DB
    サーバの) CPU
    で Web
    を回せばいいのでは?
    ここまで shared nothing
    な構成をあまり崩してないので可能
    同一ワーカ内のリクエスト間でキャッシュを使ってる程度

    View full-size slide

  48. 立っているインスタンスは DB
    サーバでも使え!

    View full-size slide

  49. RoadRunner
    分散構成: 310198
    s1
    に nginx + rr + redis
    s2
    〜 s5
    に rr + mysql
    そもそも CPU
    処理でネイティブコードの言語に
    勝てないのは自然
    PHP
    なんだから横に並べてスケールさせるでい
    いでしょ
    これで本選 2
    位スコア(242,653
    )を超える
    CPU
    資源に余裕ができた

    View full-size slide

  50. あとはプロファイル見ながら地道な改善

    View full-size slide

  51. Redis
    に master_version
    移行: 317157

    View full-size slide

  52. obtainLoginBonus
    の N+1
    対策: 335023

    View full-size slide

  53. one_time_token
    の Redis
    化: 346461

    View full-size slide

  54. viewer ID check
    の Redis
    化: 352674
    やっと本選優勝スコアに勝利

    View full-size slide

  55. 負荷割合の変更 + PGO + PHP 8.2: 370253
    おまけで PGO (Profile Guided Optimization)
    も試してみた
    が、負荷割合変更のほうが大きそう
    PHP 8.2
    にするとメモリ消費量はちょっと減る

    View full-size slide

  56. ここで打ち止め
    もっと時間を使えばもう少し伸ばせそう
    予算と可処分時間が尽きてきたので一旦ここで打ち止め

    View full-size slide

  57. 知見まとめ

    View full-size slide

  58. DB
    ネックの間は PHP
    でも Go
    でもほぼ変わらない

    View full-size slide

  59. ボトルネックが言語・処理系側へうつっていくと
    (当然ながら)言語・処理系固有の知識が必要に

    View full-size slide

  60. PDO
    での N+1
    改善は多少煩雑
    ヘルパを作るほうがよい

    View full-size slide

  61. PDO
    の new
    は persistent
    でも遅くなる

    View full-size slide

  62. RoadRunner
    や Swoole
    などの AltFPM

    どうせ必要になるので
    素振りして移行を手早く確実に

    View full-size slide

  63. 完全な I/O
    ネックでない負荷性質なら
    Swoole
    より RoadRunner
    の方が
    性能が出る可能性がある

    View full-size slide

  64. PGO
    は目に見えては効かないが
    JIT
    は I/O
    のボトルネックを解消していけば効く

    View full-size slide

  65. PHP 1
    台では意外とあっさり CPU
    がサチる

    View full-size slide

  66. 全マシンをフルに使えば
    Go
    でフルに使えてない状態よりは
    PHP
    の方が速くできる可能性がある

    View full-size slide

  67. しかし同様に Go
    でも全マシンをフルに使えば
    処理系性能では単に大きく負けてしまう

    View full-size slide

  68. NaruseJun
    組の改善速度は化け物

    View full-size slide

  69. お前が信じる PHP
    を信じろ

    View full-size slide

  70. おしまい

    View full-size slide