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

Perl GraphQL 高速化バトル 2026年5月版

Perl GraphQL 高速化バトル 2026年5月版

Avatar for AnaTofuZ

AnaTofuZ

May 23, 2026

More Decks by AnaTofuZ

Other Decks in Technology

Transcript

  1. 自己紹介 八雲アナグラ @AnaTofuZ 📍 山梨県甲府市 💻 Web アプリケーションエンジニア Perl が好きですが最近はRuby

    が主戦場です 🐪 Houtou.pm / 💎Kofu.rb たぶん7~8 月にOzara.pm ということでやるので はないか 🎮 最近の趣味は真・女神転生3 とデジモンです
  2. GraphQL とは Meta が発表したクエリ言語 GraphQL API を提供するサーバー側と利用するクライアントに分かれる 宣言的UI とfragment colocation

    の相性が最高 REST と GraphQL の違い REST GraphQL エンドポイント URL + HTTP メソッド 単一のところにPOST N+1 問題 複数エンドポイントを叩く DataLoader でPromise を作りバッチ化 型システム 使わなくても良い スキーマで厳密に定義
  3. GraphQL のサーバーサイド処理 フェーズ 役割 ① パース クエリ文字列を解析し、どのフィールドを取得するか決定する ② 実行 フィールドごとにリゾルバを呼び出し、結果を

    JSON に組み立てる ほぼ現代のプログラミング言語処理系と同じようなフローをたどる クエリ文字列 ──[ ① パース]──> 構文木(AST) ──[ ② 実行]──> レスポンス query { books(ids: ["1","2"]) { title author { name } } }
  4. フェーズ①: パース — クエリ文字列 → AST ↓ パーサが構造を解析 実行エンジンはこの AST

    を元に「どのリゾルバを・どの順で呼ぶか」を決定する パーサがクエリ文字列を走査して 構文木 (AST) に変換する query { books(ids: ["1","2"]) { title author { name } } } OperationDefinition (query) └─ Field: books ├─ Argument: ids = ["1", "2"] └─ SelectionSet ├─ Field: title └─ Field: author └─ SelectionSet └─ Field: name
  5. フェーズ②: 実行エンジン ① books リゾルバ ids: ["1","2","3"] を受け取り、本のリストを返す ② title

    / author リゾルバ 各本オブジェクトから title 文字列と author オブ ジェクトを取得 ③ author.name リゾルバ 著者オブジェクトから name 文字列を取得 ④ JSON 組み立て 全フィールドの結果をネストした構造にまとめてレス ポンスを生成
  6. GraphQL のAPI を作るには 基本的には普通のウェブサーバーとして実装する リファレンス実装はJavaScript で書かれている graphql-js 主要様々な言語でもライブラリが存在 Golang(gqlgen), Ruby(graphql-ruby),

    … graphql-js とのコンパチ実装とその言語に最適化されたものがある GraphQL の仕様とは別に業界でよく使われる仕様もある Apollo Federation Relay 基本的に現代では困ることはない
  7. graphql-perl アーキテクチャ Pure Perl 実装の特徴 パーサ: Pegex XS を一切使わない Pure

    Perl の PEG パーサ 実行エンジン: 再帰関数呼び出し フィールドの深さ × 幅だけ再帰スタックが積まれる 内部表現: ExecutionPartialResult フィールド数だけ HashRef の生成・破棄とマージが発生 → Perl の参照カウント管理のオーバーヘッド クエリ文字列を1 文字ずつスキャン ルールがマッチするたびに → Perl メソッド呼び出し → Perl の HashRef ノード生成 _execute_fields() → リゾルバ呼び出し → _complete_value() → _execute_fields() ← 再帰 → ... # すべてのフィールド結果が HashRef として受け渡される { data => $value, errors => [] } # フィールドの数だけ生成・破棄
  8. 実行エンジン: graphql-perl の場合 (1/2) 最終目標: {data => ..., errors =>

    []} の HashRef を組み立てて JSON 化する books リゾルバ呼び出し オブジェクト型 → 再帰して次ページへ フィールド列挙 (Query 階層) → [book1, book2, ...] フィールド列挙 (Book 階層) ↓
  9. 実行エンジン: graphql- perl の場合 (2/2) Book 階層では title ・ author

    の2 フィールドを列 挙してリゾルバを呼ぶ author もオブジェクト型なので再帰 Author 階層で name リゾルバを呼び、スカラー値を確 定 HashRef マージ 確定したスカラー値を下から上へ積み上げ、最終的な {data, errors} 構造を組み立てる コストが積み重なる箇所: 再帰のたびに HashRef を生成・マージ フィールドの深さ × 幅だけすべて Pure Perl で繰り 返す
  10. title リゾルバ author リゾルバ オブジェクト型 → 再帰 name リゾルバ フィールド列挙

    (Book 階層) → 'Perl 入門' スカラー型 → 確定 → {id:10, name:'Alice'} フィールド列挙 (Author 階層) → 'Alice' スカラー型 → 確定 HashRef マージ {data=>{books=> [{title,author:{name}}]}, errors=>[]} graphql-perl のつらいところ 相性がいいのかというと言語特性的な意味で結構大変 並列処理のAPI が言語レベルで無いのでPromise 作っても直列実行 ストリーミング処理がprefork のモデルだとつらい いわゆるモダンなPerl のモジュールをかなり利用している Role::Tiny, Tipe::Tiny, Moo… Web アプリケーションを書く上では使うのには問題ないが、フレームワークという立場だとパフォー マンスがボトルネック PurePerl のウェイトが強いので遅い Pegex でパースをしているのでなかなか遅い 構文解析式文法(PEG )と正規表現(Regex )のライブラリ DSL を喰わせると正規表現で動的に処理系が生成される Pegex 自体は機能のリッチさと比較してもとても高速 なんだがGraphQL 特化ではないので…
  11. graphql-perl のつらいところ ほぼ開発が止まっており最近のGraphQL 仕様に追従できてない Apollo Federation とかも当然非対応 そもそもPerl 界隈でGraphQL やってる人が少ない

    今から新規開発でGraphQL をPerl でやりたいかというと…? Perl 人工も減っているため… というわけで遅いので気合いのモンキーパッチを様々な人が作ったり、運用でカバーしているのが実情
  12. とはいえ2026 年 生成AI が台頭してきた 驚くべきことに現代のAI エージェントはXS/C をかなり生成できる メモリリーク関連はコンテキストにperldoc のXS ベストプラクティスを喰わせるとかなり減る

    Perl のGraphQL ライブラリについては豊富なテストがある そもそもGraphQL なので正しい挙動は言語問わず転がっている Perl という面でもgraphql-perl がすでにあるのでAPI の互換性を保てばテストとしては同じ という訳でXS で書いていくぞ GraphQL ライブラリ自体 周囲で使っているPromise ライブラリ
  13. Promise ライブラリとGraphQL Perl はPromise っぽい概念が言語コアにない 今Future っていう名前でリファレンス実装がすすみつつはある 早いPromise を使うならPromise::XS 特にGraphQL

    だとDataLoader で使う # DataLoader が各 author を非同期で並行取得する仕組み my $deferred = Promise::XS::deferred(); # 未解決の Promise を作る $deferred->resolve($author_data); # 後で値を入れる Promise::XS::all(@promises) # 全 Promise が揃ったら次へ ->then(sub { dispatch_batch(@_) });
  14. Promise::XS 基本的にXS で書かれたPromise ライブラリ AI ブームの以前からちょくちょくAnaTofuZ がコミットしてた 一部のメソッドが実装難易度からPerl で書かれていた 特に

    all がPerl で書かれているけどDataLoader でめちゃくちゃ使う とりあえずここをXS 化した なんかメンテ権ももらったのでcpan-release した
  15. GraphQL::Houtou compiled_ir アーキテクチャ XS パーサ + 型別 C 専用処理 graphql-perl

    からの改善点: Pegex を廃止 → XS パーサ(約80 倍速) スキーマ・クエリを事前コンパイル → 実行計画を キャッシュ フィールドループを C で処理 型別 C 専用処理: 問題: C 専用処理の外に出ると Perl に戻る ~60k ops/s で頭打ち Object/List/Abstract フィールド → C の専用処理で完結 → 高速 その他(汎用ケース) → Perl の汎用処理へ C 専用処理 ↓ 対応外のケースに到達 ↓ Perl HashRef を生成 (C→Perl) ↓ Perl の汎用処理を呼ぶ ↓ また C に戻る (Perl→C) ↑ フィールド数 × リクエスト数 繰り返す
  16. 実行エンジン: compiled_ir の場合 (1/2) C 専用処理(高速路) Object/List 型フィールドは C の中だけで処理が完結す

    る 汎用落ち パターンに合わない場合は Perl の汎用処理に落ちる → C →Perl →C の境界コストが毎フィールドで発生 この分岐がフィールドの数だけ繰り返される = フィールドが多いほど境界越えのコストが積み重な る
  17. Object/List C 専用処理 汎用ケース XS 呼び出し execute_xs_raw() C フィールドループ フィールドの型は?

    C 専用処理で完結 native outcome → 次ペー ジ Perl HashRef を生成 C→Perl 境界コスト → 次ペ ージ 実行エンジン: compiled_ir の場合 (2/2) C 専用処理の場合 native outcome のまま Writer に直接蓄積 → 境界越え なし 汎用落ちの場合 Perl HashRef を作って Perl 側で処理 → 次の XS 関数に渡すときまた HashRef から値を取り 出す → 「C →Perl →C 」の往復コストが1 フィールドごとに発 生 結果: C 専用処理を増やすほど境界越えも増え、改善が 頭打ちに
  18. Perl→C 境界コスト C 専用処理 native outcome Perl HashRef {data=>, errors=>[]}

    Writer に蓄積 Perl 汎用処理 HashRef から値を再取り出 し レスポンス XS 化 ワイ「XS にすればとりあえず早くなるやろ」
  19. 中途半端にXS にすると遅い 1. 関数呼び出しスタックの設定(C の呼び出し規約に合わせる) 2. 引数の型変換: Perl の SV*

    → C の型 3. 参照カウントの更新(Perl のメモリ管理) 4. 戻り値の型変換: C の型 → Perl の SV* これがGraphQL のオブジェクトの分実行されるぞ!!! 遅いな!! Perl の世界とXS の世界は行き来するとコストがかかる
  20. リアーキテクチャ 遅いので「ユーザーが使う関数呼び出しのAPI だけ同じだったら何もしてもよい」という感じで実装し直し AI 太郎がC 書くのがめんどくさいということだったのでPurePerl で一旦複雑な箇所を実装し直す 当初の予定通り以下の感じ 基本的に実行はXS で書いたVM

    を使う IR を多段コンパイルして最適化する AST のデバッグは情報が増えてつらいのでオプトインでよい WebApp ではスタートアップ時にいろいろできるのでキャッシュを効きやすくしてほしい
  21. GraphQL::Houtou 第4 世代 (NativeBundle) 実行全体を C に閉じる アーキテクチャ: Perl に戻るのは入り口と出口の2

    回だけ XS opaque handle: 遅延 materialize: GraphQL::Houtou::execute() (Perl) ↓ XS 呼び出し(境界越え 1 回) ↓ vm_runtime.h (C ループ) ↓ block/op を C で直接回す ↓ フィールド解決 × N ↓ Writer (C 構造体)に蓄積 ↑ Perl にレスポンス返却(境界越え 1 回) # Perl からはオブジェクトに見えるが # 内部は C 構造体へのポインタ bless( \$ptr, 'GraphQL::Houtou::Runtime::Cursor' ) // args は C のまま保持 // リゾルバ呼び出し直前にのみ Perl HashRef を生成 if (payload->static_args_sv) { return SvREFCNT_inc(...) // 2 回目以降はキャッシュ }
  22. 実行エンジン: GraphQL::Houtou 第4 世代 の場合 C ループがフィールドを処理 Perl に戻るのはリゾルバ呼び出し時だけ compiled_ir

    との違い: C →Perl への折り返しがない(汎用落ちがない) 中間 HashRef の生成がない ループ自体が C なので再帰スタックが不要 XS 呼び出し (Perl→C 境界 1 回) C ループ vm_runtime.h op を順番に処理 op: books C でリゾルバ呼び出し準備 Perl リゾルバ callback → [book1, book2, ...] op: title / author C でリゾルバ呼び出し準備 Perl リゾルバ callback → 'Perl 入門' / {id:10} Writer (C 構造体) 結果を蓄積 最後に1 回 JSON 組み立て Perl → C (1 回) C ループ ├─ books: C で準備 → Perl callback → C に戻る ├─ title: C で準備 → Perl callback → C に戻る └─ name: C で準備 → Perl callback → C に戻る Writer に蓄積 (C のまま) 最後に JSON 化(1 回) C → Perl (1 回)
  23. 例題: WEB+DB PRESS Vol.129 電子書籍 API — 記事のサンプルコード N+1 問題の解決策:

    DataLoader + Promise::XS Promise::XS::all() でDataLoader がバッチで作ったPromise を集約 type Query { book(id: ID!): Book! } type Book { id: ID! title: String! author: Author! } type Author { id: ID! name: String! } # DataLoader.pm ( 記事のサンプルより) sub load { my ($self, $key) = @_; my $deferred = Promise::XS::deferred(); $self->{batch_map}{$key} = $deferred; return $deferred->promise; } # App::GraphQL より { resolve => \&Promise::XS::resolved, all => sub { Promise::XS::all(map { ... } @_) } }
  24. 計測結果: 全4 パターンの比較 構成 計測時間 実行文数 関数呼び出し数 graphql-perl + PXS

    0.20 327ms 1,124,694 379,675 graphql-perl + PXS 0.21 327ms 1,110,459 362,826 Houtou compiled_ir ( リアーキ前) 144ms 291,629 119,598 Houtou 第4 世代 ( 現在) 138ms 414,486 126,285 クエリ: books(ids: ["1","2","3"]) { id title author { id name } } — 3 冊 + 各Author
  25. Flamegraph: graphql-perl + PXS 0.20 Flame Graph Reset Zoom Search

    Ro.. GraphQL::Execution::_complete_value Mo.. Moo:.. G.. G.. Gr.. GraphQL::.. GraphQL::Directive.. GraphQL.. Graph.. GraphQL.. GraphQL::Sc.. Mo.. m.. Moo::ex.. Role::.. GraphQL::Execution::_complete_value.. GraphQL::Execution::_exec.. GraphQL::Execution::_complete_va.. G.. Graph.. T.. GraphQL::Execution:.. Moo:.. Context::dispatch_all Gr.. GraphQL::Execution::_complete_value GraphQL:.. Role.. DataLoader::dispatch Mo.. GraphQL::Schema::BEGIN@11 Ty.. GraphQL::Execution::_complete_.. Role:.. G.. Promise::XS::Deferred::.. Moo::with GraphQL::Execut.. Ro.. m.. Role.. Gr.. Moo::_.. Moo::_.. Moo:.. main::BEGIN@6 T.. Moo.. G.. GraphQL::Execution::_complete_v.. Moo::R.. Moo::.. Gra.. Gr.. Graph.. Role::T.. Moo::_U.. T.. Moo::_U.. GraphQL::Sche.. Mo.. GraphQL::Type::List::_complete_value Gr.. Gr.. Gr.. GraphQL::Type::Object::_compl.. GraphQL::Execution::_execute_operation Gra.. Moo::_s.. Gr.. GraphQL::Type::Ob.. GraphQL::Execution:.. Mo.. Moo:.. Mo.. GraphQL::Execution::_complete_value.. Rol.. G.. main::run_once GraphQL::Execution::_complete_va.. GraphQL::Execution::execute G.. GraphQL::Execution.. Graph.. GraphQ.. T.. GraphQL::Execution::_execute_fields Graph.. T.. 観察ポイント ホットパスに Pure Perl が並ぶ: GraphQL::Execution の再帰呼び出し Pegex パーサの文字スキャン Promise::XS::all → Promise::XS::Promise- >all (Perl) _complete_value / _execute_fields の相互再 帰
  26. Flamegraph: Houtou compiled_ir Flame Graph Reset Zoom Search Moo::_Utils::_require GraphQL::Houtou::Directive::BEGI..

    main::BEGIN@11 G.. Types.. Type::L.. M.. GraphQL::Houtou::Execution::execute Rol.. Gra.. main::run_once M.. Ro.. GraphQL::Houtou::Directive::B.. Role::Tiny::apply_roles_t.. Grap.. G.. Type.. GraphQL::Houtou::XS::Execution::.. GraphQL::Houtou::Schema::BEGIN@11 G.. Type.. M.. GraphQL::Houtou::Sche.. T.. Grap.. T.. GraphQL::Houtou::XS::Execution::.. M.. T.. Mo.. E.. Mo.. Moo::with Gra.. Mo.. Role::Tiny::_check_roles G.. GraphQL::Error.. Ty.. Ty.. GraphQL::Houtou::Role:.. Moo::_Utils::_load_module Type::.. Ty.. Type:.. G.. Moo::Role::_require_module Ty.. Typ.. Mo.. GraphQL::Ho.. M.. Moo.. M.. Type:.. Typ.. G.. compiled_ir 世代(リアーキ前) XS パーサ + 型別 C 専用処理: パーサは XS (Pegex を廃止) フィールドループは C の専用処理で実行 ただし対応外のケースで Perl HashRef に折り返す NYTProf では: execute_xs_raw が 203 回呼ばれ 合計 8.5ms 残りは Perl 側の型チェックやスキーマ構築 # GraphQL::Houtou::Execution より return GraphQL::Houtou::XS::Execution::execute_xs_raw( $schema, $doc, ... ); # → XS が compile_ir を呼ぶ
  27. Flamegraph: Houtou 第4 世代 Flame Graph Reset Zoom Search M..

    GraphQL::Houtou::Runtime::OperationCompiler::compile_ope.. Grap.. Gr.. GraphQL::Houtou::execute E.. main::run_once Role::Tiny::With::with Grap.. Role::Tiny::_require_module Gra.. GraphQL::Hout.. Gra.. Graph.. Role::Tiny::_check_roles Grap.. Role::Tiny::apply_roles_to_package Gra.. Ty.. Type.. Ty.. GraphQL::Error::BEG.. Role::Tiny::_load_module GraphQL::Houtou::Runtime::SchemaGraph::_compile_native_pr.. GraphQL::Error::BEGIN@8 GraphQL::Houtou::Runtime::SchemaGraph::compile_program G.. E.. GraphQL::Ho.. main::BEGIN@10 GraphQL::H.. Mo.. G.. GraphQL::Houtou::Role::Leaf::BEGIN@9 G.. G.. GraphQL::Houtou::Runtime::Oper.. GraphQL::Houtou::Schema::BEGIN@10 G.. Gra.. Type:.. Type:.. G.. T.. Gr.. GraphQL::Houtou::Runtime::NativeRuntime::execute_document GraphQL::Houtou::Runtime::OperationCompiler::_.. G.. Type.. GraphQL::Houtou::Runtime::NativeRuntime::compile_program GraphQL::Houtou::Directive::BEGIN@11 Graph.. G.. T.. Type.. Ty.. Type::.. Graph.. Gr.. NativeBundle 完全C 化 フレームグラフが著しく薄い! NativeBundle アーキテクチャ: Perl → XS 呼び出し (1 回) ↓ C ループ (vm_runtime.h) ↓ フィールド解決 × N ↓ Writer (C 構造体) ← Perl にレスポンス返却 (1 回)
  28. 第4 世代の工夫① XS opaque handle フィールド100 個 → HashRef 100

    回生成 GraphQL::Houtou の内部表現 実行ループ中は Perl オブジェクトを作らない → 最後の1 回だけ {data => ..., errors => []} を 生成 graphql-perl の内部表現 # フィールドの解決結果 = Perl HashRef my $result = { data => { title => "Perl 入門" }, errors => [], }; # 毎フィールドごとに生成・破棄 # Perl から見ると「オブジェクト」 # 実体は C 構造体へのポインタ bless( \$ptr, 'GraphQL::Houtou::Runtime::Cursor' ) # ↑ ここに C struct のアドレスが入っている // C 側では構造体として直接操作 gql_runtime_vm_cursor_t *ptr = INT2PTR(...); ptr->block_index = ...;
  29. 第4 世代の工夫② 遅延 materialize (args) 第4 世代: C 内に保持、必要な時だけ生成 args

    なし → singleton 返却(0 アロケーション) 静的引数( limit: 10 )→ 初回のみ生成、以降キ ャッシュ リスト1000 件でも HashRef 生成は 1 回だけ 従来: 毎回 Perl HashRef を作る フィールド解決 → args HashRef 生成 → リゾルバ呼出 フィールド解決 → args HashRef 生成 → リゾルバ呼出 フィールド解決 → args HashRef 生成 → リゾルバ呼出 (リスト1000 件なら1000 回) // NativeBundle の中では引数を C のまま保持 gql_runtime_vm_native_args_payload_t { char **names; IV count; SV *static_args_sv; // キャッシュ先(最初はNULL ) }
  30. 第4 世代の工夫② 遅延 materialize (response) 状況 従来 遅延 materialize args

    なしフィールド 毎回空の HashRef 生成 singleton (0 アロケーション) 静的 args ( limit: 10 ) リストN 件でN 回生成 初回のみ、以降キャッシュ レスポンス組み立て ループ中に都度 HV/AV 生成 Writer が最後に1 回まとめて生成 実行ループ中はフィールド解決結果をPerl オブジェクトではなくC の構造体化 C 実行ループ └─ フィールド解決 → Outcome (C 構造体) └─ フィールド解決 → Outcome (C 構造体) └─ ... └─ 最後に1 回だけ → {data => HV, errors => AV} を生成
  31. 第4 世代の工夫③ 3 層キャ ッシュ 型情報・リゾルバ関数ポインタ・ABI コードを C 構造体としてプロセス起動後に一度だけ構築 第2

    層: クエリコンパイルキャッシュ(初回のみ) NativeProgram は JSON シリアライズ可能 → Persistent Query へ 第3 層: op 内キャッシュ NativeProgram が再利用されるほどヒット率が上がる 毎リクエストでやること(これだけ) 1. NativeProgram をキャッシュから取得 2. 変数の準備( prepare_variables() ) 3. C ループで実行 4. Writer でレスポンス生成 パース・コンパイル・C 構造体構築はすべてスキップ 第1 層: スキーマキャッシュ(起動時1 回) sub build_native_runtime { # 2 回目以降はキャッシュを返す return $self->{_compiled_native_runtime} if $self->{_compiled_native_runtime}; ... } クエリ文字列 → NativeProgram (C 構造体) (変数値を含まない実行計画) → 同じクエリなら何度でも使い回せる // static_args_sv が埋まっていれば即返す if (payload->static_args_sv) { return SvREFCNT_inc(payload->static_args_sv); } // 初回だけ HashRef を作る
  32. 第2 層の応用: Persistent Query デプロイ時・起動時(一度だけ) リクエスト時(パース・コンパイルなし) NativeProgram は JSON にシリアライズしてディスクに保存できる

    # クエリをコンパイルして JSON 保存 $schema->dump_program_descriptor($document, '/cache/hero_query.json'); # NativeRuntime + NativeProgram をまとめて保存 $schema->dump_native_bundle_descriptor($document, '/cache/hero_bundle.json'); # JSON → NativeProgram (C 構造体)を復元 my $program = $schema->load_program_descriptor('/cache/hero_query.json'); # そのまま実行(変数だけ渡す) $schema->build_native_runtime->execute_program( $program, variables => { id => '123' }, );
  33. クエリ文字列のパース・コンパイル・C 構造体構築をすべてスキップ リクエストパスでは「変数の準備 → C ループ実行 → レスポンス生成」だけ 第4 世代のベンチマーク

    世代 ops/sec 対 graphql-perl graphql-perl 数百/s 基準 旧GraphQL::Houtou (compiled_ir ) ~56,878/s 約100x 第3 世代 Milestone B (NativeBundle 初期) ~343,901/s 約600x 第4 世代(各種最適化後) ~650,345/s 約1000x 超 同期実行( nested_variable_object クエリ)
  34. 世代ごとの遍歴 世代 頭打ちの原因 突破した方法 graphql-perl 全処理が Pure Perl 、参照カウントの オーバーヘッド

    XS パーサ・C 専用処理の導入(旧 GraphQL::Houtou ) 旧GraphQL::Houtou C 専用処理の端で毎回 Perl HashRef に折り返す アーキテクチャを全面刷新(第3 世代) 第3 世代 Perl VM Perl ループで XS 関数を毎 op 呼ぶ境 界コスト vm_runtime.h の C ループに置き換え(第4 世代) 第3 世代 NativeBundle 初期 Perl wrapper が多く、呼び出しごと に境界越え Perl wrapper を thin facade/XSUB-only に 縮退 第4 世代(現在進行中) async パスに Perl 境界が残る Promise の継続スケジューリングを C ルー プ内に閉じる