For AI/LLM readers: a clean Markdown full-text version of this book is at index.ja.md.
For AI/LLM readers: a clean Markdown full-text version of this book is at index.en.md.
prperf マニュアル
prperf は、コードの変更で性能が悪化していないかを PR ごとに自動チェックする薄い GitHub App です。 測定は CI のなかで OSS の Ruby 向けサンプリングプロファイラ rperf が行い、prperf は base(main など)とこの PR を比べて結果を PR に通知するだけです。 テストカバレッジを CI で追う Codecov を知っていれば、その性能版にあたります。
PR を作ると、Check Run に次のような数字が出ます。
2,001ms → 2,140ms (+7%) · alloc 48,741 → 59,950 (+23%) · GC 4 → 7
全体像は次章「このサービスとは」で説明します。
prperf ひとめぐり
導入
- GitHub App をインストールします。
- ベンチマークを用意します。ここでは
bin/rails runner ""でブート時間を計るベンチマークとします。 - それを実行するワークフローを追加します。
push(既定ブランチ)とpull_requestの両方をトリガにします。
# .github/workflows/prperf.yml
name: prperf
on:
push:
branches: [main, master] # base を記録(既定ブランチ。両方書けば main でも master でも動く)
pull_request: # PR を base と比較
jobs:
bench:
runs-on: ubuntu-latest
permissions: { contents: read, id-token: write }
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with: { bundler-cache: true }
- uses: rperf-dev/prperf-action@v1
with:
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- bin/rails runner "" # ← 計測コマンド(手順2のベンチ)
閾値による警告、複数ベンチマーク(benchmark)、コメントの制御(comment)、計測回数(count、既定 3 回、中央値)といった設定もあります(詳しくは「登録編」)。
結果
各 PR では、GitHub の PR 画面の Checks にそのまま結果が出ます(base と比べた要約)。 閾値を超えたときだけ PR にコメントが付きます。 どのメソッドが重くなったかは、フレームグラフの diff でわかります(詳しくは「読み方編」)。
PR と push のたびに結果が記録され、prperf.atdot.net でこれまでの履歴(推移)を確認できます。
prperf は CI を落とさず、シークレットも要りません。 ただし fork からの PR は計測できず、無料βの間は public リポジトリだけが対象です。
目次
1. このサービスとは
1.1 ひとことで
prperf は、コードの変更で性能が悪化していないかを PR ごとに自動チェックして PR に知らせる GitHub App です。 測定はあなたの CI の中で OSS の Ruby プロファイラ rperf が行います。 prperf 自身は、マージ先ブランチ(ふつう main)の最新の計測と、この PR の計測を比べて結果を通知するだけの薄い App です。
このマージ先ブランチ側の基準を base、PR 側を head と呼びます(GitHub の PR 用語と同じ)。 本マニュアルでは以降この呼び方を使います。
テストカバレッジを CI で追う Codecov を知っていれば、その「性能版」と思ってください。
rperf は時間(CPU)ベースのサンプリングプロファイラで、本体は「どこで時間を使ったか」を表すフレームグラフです。 prperf はそのプロファイルから実行時間、GC、アロケーションを取り出し、base と head で比較します。 PR を作ると、Check Run にこんな要約が出ます。
2,001ms → 2,140ms (+7%) · alloc 48,741 → 59,950 (+23%) · GC 4 → 7
このコミットで性能がどう変わったかを、マージ前の PR の時点で気づけるようにするのが prperf です。
1.2 何をするか、しないか
する:
- PR ごとに base から head への性能差(アロケーション、GC、時間)を Check Run に表示
- 閾値を超えたときだけ PR にコメント(sticky、通知は静か)
- フレームグラフの diff で「どのメソッドが重くなったか」を可視化
しないこと:
- 本番監視ではありません。 Datadog や Grafana の代替ではなく、補完です(本番の継続監視はそちら、PR 時点の回帰検知が prperf)。
- CI を落としません。 判定はあくまで参考表示で、Check の conclusion は常に success です。
- あなたのコードをサービス側で実行しません。 測定はあなたの CI の中で行われ、prperf はその結果(プロファイル)を受け取って比較するだけです。 これが「薄い App」として成立する理由で、セキュリティとコストの両面で軽くなります。
つまり prperf は、計測と判定を CI 側に置き、サービス側は比較と通知だけを担当します。
1.3 全体像
あなたの CI (GitHub Actions)
└─ prperf-action
├─ rperf でベンチを N 回計測
└─ プロファイル(.json.gz)を prperf サーバーへアップロード
│ (GitHub OIDC トークンで認証 → シークレット設定不要)
▼
prperf サーバー
├─ base と head を比較
└─ Check Run / PR コメントに結果を通知
この構成では CI が計測を実行し、prperf サーバーは受け取ったプロファイルを base と head の組として比較します。
1.4 使う側の体験
- GitHub App をリポジトリにインストール
- 提供する GitHub Action をワークフローに数行追加
- PR を作ると Check と PR コメントに結果が付く
ワークフローは 1 本です。 push(既定ブランチ)で base を記録し、pull_request でその base と比較します。 base か head かは、prperf が OIDC トークンの ref から判定します。
# .github/workflows/prperf.yml
name: prperf
on:
push:
branches: [main, master] # base を記録(既定ブランチ)
pull_request: # PR を base と比較
jobs:
bench:
runs-on: ubuntu-latest
permissions: { contents: read, id-token: write }
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with: { bundler-cache: true }
- uses: rperf-dev/prperf-action@v1
with:
run: bundle exec ruby bench/run.rb
1.5 なぜ信頼できるか(設計の勘所)
判定では、実行時間よりもアロケーション数と GC 回数を重視します(CI の実行時間は ±10〜20% ブレるためです)。 rperf が測る中心は時間(フレームグラフ)ですが、その時間は参考にとどめます。 結果には時間、GC、アロケーションのすべてと、フレームグラフが出ます。
シークレットは要りません。 認証は GitHub Actions の OIDC トークンで、API キーの発行や管理が不要です。
通知は静かです。 Check Run は常設の置き場で通知ゼロ、コメントは閾値超過時のみ、PR につき 1 通を編集します。
重くなった理由まで分かります。 フレームグラフ diff で、重くなったメソッドを特定できます。
1.6 誰のためか
性能回帰を PR で止めたい、public な gem やライブラリの作者に向いています。 依存の更新やリファクタで、気づかないうちにアロケーションや起動が重くなるのを、PR の時点で止められます。
性能が UX や売上に直結する private な Rails アプリのチームにも向いています。 重くなる変更を、本番に出る前に、レビューと同じ場所で捕まえられます。
料金は、public リポジトリが無料、private が有料プランです(現在は無料βのため public のみ)。
1.7 このマニュアルの読み進め方
- 登録編:インストールとワークフロー追加
- ベンチマークの書き方、Rails クイックスタート:何を測るか
- 読み方編、フレームグラフの読み方:結果の読み取り
次は「登録編」へ。
2. 登録編
prperf を使い始めるまでの流れです。 所要 10〜15 分。 サービスの概要は「このサービスとは」を参照してください。
やることは 3 つです。
- GitHub App をインストールする
- ベンチマークを用意する
- それを実行するワークフローを追加する
2.1 前提
rperf 0.10 以上を Gemfile に入れていることが必要です。
prperf はプロファイルに埋め込まれた meta と summary を使います。
古い rperf では action が明確なエラーで止まります。
計測対象のベンチマークコマンドがあることも必要です(後述)。
対象は public リポジトリです。 private リポジトリは有料プランで提供します(現在は無料βのため public 専用)。
2.2 GitHub App をインストール
prperf の GitHub App ページからリポジトリにインストールします。 これにより、prperf がそのリポジトリの Check Run と PR コメントを書けるようになります。
2.3 ベンチマークを用意する
何を測るかが、検知できる回帰の範囲を決めます。 良いベンチマークは、決定的で、気にしている経路を通り、そこそこの規模があります。 何をどう測るかはプロジェクトしだいなので、書き方とプロジェクト別の例は「ベンチマークの書き方」にまとめています。
この登録編では、例として Rails アプリの起動(boot)を計ります。
ベンチマークは bin/rails runner "" だけです。
アプリをブートして空のスクリプトを走らせるので、ベンチ用のファイルを書かずにそのまま使えます。
次の節で、これを rperf でくるんでワークフローに置きます。
2.4 ワークフローを追加
用意したベンチを実行するワークフローを追加します。
push(既定ブランチ)と pull_request の両方をトリガにします。
push が base を、pull_request が head を供給します。
base と head の区別は、prperf が OIDC トークンの ref から判定します。
既定ブランチは main と master の両方を書いておけば、どちらでも動きます。
既定ブランチへの push が一度も無いと比較対象が無く、「比較対象なし、今回の数値のみ」になります。
# .github/workflows/prperf.yml
name: prperf
on:
push:
branches: [ main, master ] # base を記録(既定ブランチ)
pull_request: # head を base と比較
jobs:
bench:
runs-on: ubuntu-latest
permissions:
contents: read # checkout 用
id-token: write # OIDC アップロードに必須
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: rperf-dev/prperf-action@v1
with:
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- bin/rails runner ""
run: には、計測したいコマンドを rperf record で包んで渡します。
これが profile を 1 本以上書き出す必要があります。
出力先は --snapshot-dir "$PRPERF_DIR" を指定します($PRPERF_DIR は action が用意します)。
permissions: id-token: write は必ず付けてください。
これが無いと OIDC トークンが取れず、アップロードできません。
contents: read は actions/checkout がリポジトリを取得するためです。
permissions: を書くと、挙げていない権限は none になるので、両方を明示します。
2.5 閾値とコメントの設定(任意)
閾値は、回帰したときに Check Run で警告(⚠️)を出すための仕組みです。
どこからを回帰とみなすか、その基準は指標ごとに自分で決められます。
基準は、base から head への各指標の増加(アロケーション、GC、時間など)の上限として与えます。
超えると Check Run に ⚠️ が付き、comment 設定に応じて PR コメントが出ます。
閾値は任意で、設定しなければ数字は出ますが、警告もコメントも付きません。
回帰したら警告してほしいときだけ設定します。
設定は全部ワークフローに書きます(別ファイルは不要)。
全体設定を job の env に一度だけ書き、必要ならベンチごとに上書きします。
jobs:
bench:
runs-on: ubuntu-latest
permissions: { contents: read, id-token: write }
env:
PRPERF_DEFAULT_THRESHOLDS: | # 全ベンチ共通の既定
alloc: "+10%"
total_ms: "+20%"
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with: { bundler-cache: true }
- uses: rperf-dev/prperf-action@v1
with:
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- bin/rails runner ""
閾値のキーと、まず設定するなら使いたい値は次のとおりです(prperf 自体に既定値はなく、設定して初めて効きます)。
| キー | 推奨デフォルト | 意味 |
|---|---|---|
alloc |
"+10%" |
アロケーション数の増加。"+5000" のように絶対値でも書ける |
gc_count |
"+2" |
GC 回数(minor+major)の増加 |
total_ms |
"+20%" |
実行時間の増加。ノイズが大きいので相対(%)で |
cpu_ms |
"+15%" |
CPU 時間の増加 |
method |
(指定なし) | 名前を挙げたメソッドの self 占有率が、その % を超えたら。例 { "JSON.generate": "15%" } |
値は、summary 系が "+N%"(相対)か "+N"(絶対)、method 系が "N%" です。
不正な値は無視され、Check Run に警告が 1 行出ます(CI は落ちません)。
相対閾値(+10%)はベンチをまたいで素直に機能します。
絶対閾値や method はベンチごとに意味が変わるので、必要なときだけベンチ別に上書きしてください。
コメントの出し方は comment 入力で制御します(既定 on_threshold)。
| 値 | 挙動 |
|---|---|
on_threshold |
閾値超過時のみ PR コメント(既定) |
always |
毎回コメント |
never |
コメントしない(Check Run だけ) |
コメントは PR につき 1 通で、push のたびに同じ sticky コメントを編集します(通知が増えません)。
2.6 action の入力一覧
| 入力 | 既定 | 説明 |
|---|---|---|
run |
(必須) | 計測コマンド。.json.gz を 1 本以上吐くこと |
prepare_run |
"" |
計測前に1回だけ走るセットアップ(fixture 生成や seed など)。計測には含めない |
count |
3 |
計測回数。サーバーは中央値で比較 |
benchmark |
default |
ベンチ系列名。1 コミットで複数ベンチを独立比較できる |
thresholds |
"" |
このベンチの閾値(全体設定をキー単位で上書き) |
comment |
on_threshold |
コメントの出し方 |
server |
https://prperf.atdot.net |
prperf サーバー(差し替え可) |
upload |
true |
false で計測のみ(アップロードしない) |
通常は run だけを指定し、必要に応じて benchmark、thresholds、comment を足します。
2.7 複数ベンチマーク
1 コミットを複数のベンチで測れます。
ステップを分けて、それぞれに違う benchmark 名を付けるだけです。
サーバーは各ベンチを自分の base と比較し、1 つの Check Run にまとめて表示します。
- uses: rperf-dev/prperf-action@v1
with:
benchmark: boot
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- bin/rails runner ""
- uses: rperf-dev/prperf-action@v1
with:
benchmark: render
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- ruby bench/render.rb
ワークフローが 1 本なので、base 側(push)と PR 側で同じ benchmark 名が自然に揃います。
各系列が自分の base を持つには、この一致が必要です。
2.8 動作確認
- まず既定ブランチ(main または master)に push します。ワークフローが走り、base スナップショットがサーバーに届きます。
- 適当な PR を作る。ワークフローが走り、Check Run に数字が出れば成功です。
- アップロード結果のリンクは各ジョブの Summary にも出ます。
Check Run に数字が出れば、base の記録と PR 側の比較がどちらも動いています。
2.9 制約
fork からの PR は計測できません。
GitHub は fork 起因のワークフローに id-token: write を与えないため、OIDC トークンが取れません。
同一リポジトリのブランチ PR は問題ありません。
アップロードの失敗(プラン上限、レート制限、サーバーエラー)は警告のみで、ステップは成功扱いです。 計測コマンド自体の失敗だけがステップを落とします。
無料β期間中は public リポジトリのみです。 private は有料プランで近日提供予定です。
3. ベンチマークの書き方
prperf が出す数字は、何を測るかでほぼ決まります。 ベンチマークの設計は、この章で最も結果を左右し、最も手間がかかるところです。
3.1 ベンチマークとは
prperf にとってのベンチマークは、run: に渡すコマンドです。
多くの場合は小さな Ruby スクリプト(例 bench/main.rb)を rperf record で包みます。
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- ruby bench/main.rb
rperf は Gemfile に入れておきます(0.10 以上。bundle exec rperf で呼ぶためです)。
action はこれを count 回(既定 3)まわし、サーバーが中央値を base と比較します。
あなたが書くのは bench/main.rb の中身、つまり代表的な処理を一定量こなすスクリプトです。
3.2 良いベンチマークの条件
良いベンチマークは次の三つを満たします。
- 気にしている処理を通す。そのコードに触らない PR では数字が動きません。
- 決定的である(毎回まったく同じ仕事をする)。さもないと alloc や GC がブレて、回帰ではないのに警告が出ます。
- 一定量こなす。一瞬で終わるとサンプルが少なく、結果が不安定になります。
回帰判定の軸になるのはアロケーション数です。 ベンチマークが決定的なら、アロケーション数は PR 間で 1 個単位まで安定し、わずかな増加も捉えられます。 GC 回数も決定的で数えやすいので、併せて表示します。 逆にベンチマークがブレると、この安定性が失われます。
3.3 書き方
bench/main.rb は、固定入力を一度用意し、ウォームアップしてから、本番のループを十分な回数くりかえす形が基本です。
# bench/main.rb
require "json"
require_relative "../config/environment" # 必要なら(Rails 等)
# 1) 固定の入力を一度だけ用意(乱数・時刻・ネットワークを使わない)
DATA = { "users" => Array.new(100) { |i| { "id" => i, "name" => "user#{i}" } } }
# 2) ウォームアップ(初回限りの遅延読み込み・初期化を計測から外す)
JSON.generate(DATA)
# 3) 本番: 十分な回数くりかえす
5_000.times do
JSON.generate(DATA)
end
入力は固定し、rand や Time.now、DB、外部 API、ファイルの列挙順などに依存させません。
どうしても乱数が要るなら srand(42) で固定します。
ウォームアップで、初回だけ走る処理(autoload、定数初期化、遅延ロード)を計測対象から外します。
回数(ここでは 5,000)は、全体が数百 ms から数秒になるくらいに調整します。
短すぎると不安定になり、長すぎると CI が遅くなります。
3.4 決定的か確かめる
CI に入れる前に、手元で 2〜3 回流して、アロケーション数と GC 回数が毎回同じことを確認します。
rperf stat が summary を stderr に出します。
bundle exec rperf stat -- ruby bench/main.rb
bundle exec rperf stat -- ruby bench/main.rb
2 回とも allocated_objects と GC 回数が一致すれば、そのベンチマークは決定的です。
ブレるなら、次を一つずつ潰します。
randやSecureRandomを使っていない(使うならsrandで固定)Time.nowやDate.todayに結果が依存しない- ネットワークや外部サービスを叩いていない
- 変動する DB 状態に依存しない(固定のインメモリデータ、fixture、固定 seed を使う)
- ファイルの列挙順(
Dir.globの順序など)に依存しない - 入力サイズが毎回同じ
原因の見当をつけるには、フレームグラフで中身を見ます。
bundle exec rperf record -o out.json.gz -- ruby bench/main.rb
bundle exec rperf report out.json.gz # viewer が開く
非決定要素を固定したら、もう一度 rperf stat で一致を確認してから CI に入れます。
3.5 前準備(任意)
ベンチマークの前に1回だけ走らせたい準備があれば、prepare_run: に書きます。
fixture の生成、DB の seed、アセットのビルドなどが該当します。
計測の前に1回だけ実行され、計測には含まれません。
- uses: rperf-dev/prperf-action@v1
with:
prepare_run: bin/rails db:prepare db:seed # 計測前に1回だけ
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- ruby bench/request.rb
失敗するとステップが落ちます。 毎回同じ状態になるよう、固定の seed や入力を使ってください。
3.6 プロジェクト別の例
ワークフローは「登録編」のものをそのまま使い、run: を各 bench/*.rb に向けるだけです。
前準備が要るベンチ(fixture の生成、DB の seed、アセットのビルドなど)だけ、prepare_run: を足します。
3.6.1 gem / ライブラリ
公開 API を、決定的な固定入力で N 回呼びます。 入力と呼び出し回数を固定したまま、公開 API の回帰だけを測れます。
# bench/main.rb
require "your_gem"
# 固定入力を決定的に組む(乱数・時刻・ネットワークを使わない)
DATA = { "items" => Array.new(200) { |i| { "id" => i, "name" => "item-#{i}" } } }
YourGem.encode(DATA) # ウォームアップ
5_000.times { YourGem.encode(DATA) }
変えるのは require、固定入力の DATA、呼ぶ API、回数です。
既存の benchmark/ スクリプト(benchmark-ips など)も流用できますが、時間ベースのループは反復回数が変わって alloc がブレるので、回数固定のループにしてください。
3.6.2 Sinatra / Rack アプリ
Rack アプリなら何でも、1 リクエストをフルスタックで N 回通します。
# bench/request.rb
require_relative "../app" # あなたの Sinatra/Rack アプリを読み込む
require "rack/mock"
app = Sinatra::Application # クラシック。モジュラーなら app = MyApp
PATH = ENV.fetch("BENCH_PATH", "/") # 測りたいパスに変更
make = -> { Rack::MockRequest.env_for(PATH, "HTTP_HOST" => "localhost") }
pump = ->(r) { b = r[2]; b.each { |_| }; b.close if b.respond_to?(:close) }
3.times { pump.call(app.call(make.call)) } # ウォームアップ
2_000.times { pump.call(app.call(make.call)) }
run: を ruby bench/request.rb にします。
変えるのは、アプリの読み込み行、app(config.ru で run しているオブジェクト)、PATH、回数です。
DB を引くなら、前準備に seed を、ワークフローに postgres サービスを足します(「Rails クイックスタート」を参照)。
3.6.3 CLI / 素の Ruby
エントリポイントをプロセス内で固定引数で N 回呼ぶと、起動コストや外部状態の影響を避けやすく、サンプルも安定します。
# bench/main.rb
require_relative "../lib/my_cli"
ARGS = %w[build --format json] # 固定の引数
200.times { MyCli.run(ARGS) } # あなたのエントリポイントを呼ぶ
実行ファイルそのものを測るなら、十分な仕事量があることを確認してから run: に直接渡します。
1 回の起動が短いとサンプルが少なく不安定なので、ループで回すか、上のプロセス内ループにしてください。
3.6.4 Rails アプリ
Rails は次章「Rails クイックスタート」で扱います。 boot、エンドポイント、典型的なクエリ、ジョブの測り方はそこにまとめています。 Roda や grape は Rack アプリなので「Sinatra / Rack」と同じ、Hanami は Rails と同じ発想で測れます。
3.7 やってはいけないこと
次のような測り方は、PR の変更内容と無関係な要因で数字が動くので避けます。
- テストスイートをそのまま測る(
rperf record -- rspec)。PR でテストを足すだけで alloc が増え、回帰と区別できません。 - 乱数や時刻、ネットワークに依存する。毎回ブレて誤検知になります。
- 短すぎる。時間がブレ、alloc も小さすぎて差が見えません。
- 関心の薄い経路を測る。PR が触らず、毎回「変化なし」になります。
- 本物の外部依存(API や DB)を使う。ネットワーク次第でブレます。
巨大な全部入りベンチマークも、どこが回帰したか分かりにくくなるので避けます。 関心ごとに分け、1 コミットで複数のベンチマークとして測ると、回帰した処理を Check Run と diff で追いやすくなります(分け方は「登録編」の複数ベンチマークを参照)。
3.8 閾値とのつながり
ベンチマークが決定的だと、きつい相対閾値(例 alloc: "+5%")を誤検知なしでかけられます。
ブレるベンチマークだと閾値を緩めるしかなく、信号が弱くなります。
まずは 1 本、PR が最も触りやすい経路を決定的に測り、base と head の差が出る状態を作るところから始めてください。
4. Rails クイックスタート
「何を測るか」で悩む前に、ほぼコピペで動く Rails 向けの出発点を 2 段階で示します。
まず ① で「数字が出る」体験を 30 秒で作り、必要なら ② に進んでください。
4.1 まず boot を測る(ファイル追加ゼロ)
bin/rails runner "" はアプリを起動して何もせず終わるので、起動そのものを計測できます。
gem 追加や initializer の重さ、autoload 構成の変化を捕まえられます。 結果は決定的で、追加ファイルも DB も要りません。
.github/workflows/prperf.yml をそのまま貼ってください。
name: prperf
on:
push:
branches: [main, master] # base を記録(既定ブランチ)
pull_request: # PR を base と比較
jobs:
bench:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: rperf-dev/prperf-action@v1
with:
benchmark: boot
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- bin/rails runner ""
この 1 本で、既定ブランチ(main または master)への push が記録した boot の base と、PR の head が比較されます。
rperf 0.10 以上を Gemfile に入れておいてください。
4.2 1 リクエストを測る(本格版)
1 つのエンドポイントを実際に動かし、リクエスト処理が通る経路のアロケーションと GC を測ります。
3 ファイルを貼るだけです。
4.2.1 計測用の環境 config/environments/benchmark.rb
production 相当だが CI で動かしやすい専用環境を作ります。
# config/environments/benchmark.rb
require_relative "production"
Rails.application.configure do
config.eager_load = true # 本番同様に全コードを読む
config.force_ssl = false # SSL リダイレクトで計測が空になるのを防ぐ
config.hosts.clear # ホスト制限を外す(ベンチ用)
config.require_master_key = false # master key 無しでも起動
config.log_level = :warn
config.consider_all_requests_local = false
end
4.2.2 ベンチ本体 bench/request.rb
アプリを起動し、固定のエンドポイントへのリクエストを Rack 経由で N 回通すスクリプトです。 レスポンスの body まで消費して、レンダリングまで含めて測ります。 最初の数回はウォームアップとして、autoload やテンプレートのコンパイルを計測から外します。
# bench/request.rb — フルスタックを通る 1 リクエストを N 回
require_relative "../config/environment"
require "rack/mock"
PATH = ENV.fetch("BENCH_PATH", "/api/health") # ← 測りたいエンドポイントに変更
app = Rails.application
build_env = -> { Rack::MockRequest.env_for(PATH, "HTTP_HOST" => "localhost") }
consume = lambda do |result|
body = result[2]
body.each { |_| } # body を消費してレンダリングまで測る
body.close if body.respond_to?(:close)
end
# ウォームアップ(autoload・テンプレートコンパイル・コネクション確立)
3.times { consume.call(app.call(build_env.call)) }
1_000.times { consume.call(app.call(build_env.call)) }
4.2.3 ワークフロー .github/workflows/prperf.yml
name: prperf
on:
push:
branches: [main, master] # base を記録(既定ブランチ)
pull_request: # PR を base と比較
jobs:
bench:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
services:
postgres: # DB を使わないなら services と db:prepare は消す
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports: [ "5432:5432" ]
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
env:
RAILS_ENV: benchmark
SECRET_KEY_BASE: dummy-for-benchmark
DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_benchmark
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bin/rails db:prepare db:seed # DB を使う場合のみ。seed は固定データで
- uses: rperf-dev/prperf-action@v1
with:
benchmark: boot
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- bin/rails runner ""
- uses: rperf-dev/prperf-action@v1
with:
benchmark: request
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- ruby bench/request.rb
この場合も、既定ブランチへの push が base を記録し、PR の head が比較されます。 boot も残すと、起動の回帰とリクエストの回帰を同じ Check Run で別系列として見られます。
4.3 変えるのはここだけ
PATH(bench/request.rb)。測りたいエンドポイントです。JSON/API エンドポイントが楽です(アセットのプリコンパイル不要、認証で弾かれにくい)。db:seed。リクエストが DB を引くなら、固定の seed データを用意します。引かないなら postgres service と db 行ごと削除します。- 回数(1,000)。全体が数百 ms から数秒になるよう調整します。
まず PATH と seed を固定し、それでもブレる場合に回数や対象経路を見直します。
4.4 うまくいかないとき
- 空の結果やリダイレクトばかり。
force_sslで 301、または認証で弾かれています。benchmark環境ではforce_ssl=false済みです。認証が要る経路なら、公開エンドポイントを選ぶか、bench/request.rbでログイン済み env を組んでください。 - アセット関連のエラー。ビュー内の asset ヘルパーが原因です。API/JSON エンドポイントを選ぶと、asset ヘルパーの影響を避けやすくなります。
- 数字が毎回ブレる。seed が固定か、リクエストに時刻や乱数が混ざっていないかを確認してください(「ベンチマークの書き方」のチェックリスト)。ローカルで
RAILS_ENV=benchmark bundle exec rperf stat -- ruby bench/request.rbを 2 回流して alloc/GC が一致するか見てください。
いずれも、結果が決定的になるよう入力を固定してから、計測の対象や回数を調整するのが近道です。
5. 読み方編
prperf の結果は 3 か所に出ます。 Check Run(数字)、PR コメント(閾値超過時)、フレームグラフ viewer(詳細確認)です。 それぞれの読み方を説明します。
5.1 Check Run
PR の Checks に prperf という Check Run が付きます。
conclusion は常に success で、prperf が CI を落とすことはありません。
判定はあくまで参考表示です。
5.1.1 タイトル
2,001ms → 2,140ms (+7%) · alloc 48,741 → 59,950 (+23%) · GC 4 → 7
base と head の主要指標が並びます。 閾値を超えた指標があると先頭に ⚠️ が付きます。
5.1.2 サマリ(本文)
サマリには次の項目が並びます。
- base / head の指標表:alloc、GC(minor/major)、GC 時間、total ms、CPU ms、最大 RSS。閾値超過の行は太字。
- メソッド diff の上位 10 行:
base self% → head self% → Δpt。占有率が増えたメソッドが分かります。 - viewer への diff リンク:フレームグラフで詳しく見るための入口。
- base が無いときは「比較対象なし、今回の数値のみ」と表示されます。
まず指標表で増加量を確認し、必要なら method diff と viewer で原因を追います。
5.1.3 どの数字を信じるか
ベンチマークが決定的なら、アロケーション数と GC 回数は実行時間より安定します。 CI ランナーが変わっても基本ブレません。 ここが回帰判定の主役です。
時間(total_ms / cpu_ms)はノイズが大きい指標です。
CI の wall time は ±10〜20% ブレます。
count(既定 3)回の中央値で比較していますが、時間系は「大きく動いたとき」だけ気にするのが安全です。
5.2 PR コメント(sticky)
閾値を超えると(comment 設定に応じて)PR にコメントが 1 通付きます。
PR につき 1 通です。
push のたびに同じコメントを編集するので、通知は最大 1 通に保たれます(コメント欄が荒れません)。
超過した指標が ⚠️ **alloc** 48,741 → 59,950 (threshold +10%) のように並びます。
複数ベンチのときはベンチ名が付きます。
comment: never ならコメントは付かず Check Run だけ、always なら超過が無くても毎回コメントします。
5.3 フレームグラフ viewer
Check Run の diff リンク、または共有 URL /view/<repo>/<sha> から開きます。
rperf の viewer をそのまま使っているので、操作はそちらと同じです。
- フレームグラフ:横幅が時間(重み)。広いほど重い。
- Top タブ(表):同じデータを、メソッドを self と累積の重み順に並べた表で見られます。フレームグラフが読みにくいときや、重い順に対処したいときに使います。
Tagsタブは rperf のラベル(tag)ごとの内訳を表で見るものです。 - diff モード:base と head の差を色で表示。占有率が増えたメソッドが赤、減ったメソッドが青。Check Run の diff リンクはこのモードで開きます。
- 時間旅行サイドバー:そのベンチ系列の過去スナップショットが並び、既定ブランチの推移を遡れます。
j/kで新しい/古いへ移動。 - メソッドの pin:Shift+クリックでメソッドを固定すると、スナップショット間の占有率の推移がスパークラインで出ます。
URL は恒久リンク(/view/<repo>/<sha>)を共有してください。
実体は短寿命の署名 URL を viewer が都度取得する仕組みで、アクセス権の剥奪が最大 10 分で効きます。
複数ベンチを使っているときは、/view/<repo>/diff?base=<sha>&head=<sha>&bench=<名前> でベンチを指定して diff を開けます。
viewer のサイドバーは 1 つのベンチ系列にスコープされるので、boot と endpoint のスナップショットが混ざりません。
5.4 ダッシュボード
/(トップ):サービスの説明ページ。/me(ログイン後):あなたが見られるリポジトリの一覧と、ベンチごとの最新結果へのリンク。public リポジトリは誰でも、private は GitHub の read 権限がある人だけに表示されます。
ログインは GitHub OAuth で、prperf 側に固有のアカウント登録はありません。 認可は常に GitHub の権限に従います(public のスナップショットはログイン不要で閲覧可)。
5.5 回帰が出たときにすること
- タイトルの ⚠️ と指標表で、何が(alloc / GC / time / 特定メソッド)どれだけ増えたかを掴む。
- diff リンクでフレームグラフを開き、赤くなったメソッドを特定する。
- アロケーション増なら「どこで余計にオブジェクトを作っているか」、GC 増ならその帰結、time 増は CI ノイズの範囲内でないかを疑う。
- 修正後に push すれば、同じ Check Run と sticky コメントがその場で更新されます。
5.6 よくある状態
- 「比較対象なし」:base の最新スナップショットがまだありません。既定ブランチ(main/master)への push が、その PR の分岐元より前に最低 1 回走っている必要があります。
- 毎回「変化なし」:その PR がベンチの通る経路に触れていないだけです。ベンチが気にしたい経路をカバーしているか見直してください。
- 数字が毎回ブレる:ベンチに非決定要素(乱数、時刻、I/O)があります。決定的にするか、時間系の閾値を緩める、または外すのが有効です。
これらは、base の有無、ベンチマークの対象範囲、非決定要素の三つを順に確認すると切り分けられます。
6. フレームグラフの読み方
フレームグラフは、プログラムがどこで時間を使ったかを示す絵です。 読み方にはコツが要るので、前提を置かずに説明します。
6.1 この絵が表しているもの
prperf(rperf)は計測中、プログラムを高い頻度で繰り返しサンプリングし、そのときどの処理が動いていたか(コールスタック)を記録します。 そのサンプルを何千枚も重ねて集計したものがフレームグラフです。
- 1 つの箱:1 つのメソッド(関数)を表します。
- 箱の幅:そのメソッド(と、その先で呼ばれた処理)に費やされた時間の割合を表します。言い換えると、その処理が写っていたサンプルの枚数です。幅が広いほど重い処理です。
- 縦の積み重なり:呼び出しの深さを表します。下が入口、上に行くほど実際に動いていた末端(leaf)です。
下の例を見てください(下が入口、上が末端)。
┌──────────┐
│ String#* │ ← 末端(leaf)。実際に CPU を使っていた処理。幅が広いほど重い
┌───┴──────────┴───┐
│ JSON.generate │ ← その呼び出し元
┌──┴──────────────────┴────────────┐
│ Integer#times │ ← ループ。幅は広いが、中身を呼んでいるだけ
┌──┴──────────────────────────────────┴──┐
│ <main> │ ← プログラム全体。下が入口(root)で、ほぼ 100% 幅
└────────────────────────────────────────┘
この絵からは次のことが読み取れます。
一番下の <main> は幅 100% ですが、これはプログラム全体を表すので当然であり、注目する箇所ではありません。
実際に時間を食っているのは、上のほうにある幅の広い箱(この例では String#*)です。
そこが、CPU が張り付いていた場所です。
6.2 幅を見る、高さは気にしない
読むときの基本は二つです。
一つは、幅だけを見ることです。 幅がコストを表します。 この箱の幅をゼロにできればその分だけ速くなる、と考えてください。
もう一つは、高さを気にしないことです。 深いネストで高い塔になっていても、幅が細ければコストは小さいです。 逆に低くても、幅が広ければ重い処理です。 深さではなく、横幅の大きい箱を優先して見ます。
6.3 読むときに陥りやすい誤解
横方向は時間の順番ではありません。 左から順に実行されたわけではなく、名前順などで並べているだけです。 タイムラインとして読まないでください。
単体表示の色には意味がありません。 箱を見分けるための色分けであって、赤いほど熱いという意味ではありません(色に意味があるのは、後述の diff モードだけです)。
一番下の箱が 100% 幅でも正常です。 それは全体を表しているだけです。
6.4 読む手順
- 上のほうにある幅の広い箱を探します。そこが時間の使いどころです。
- その箱をクリックして拡大(ズーム)します。その部分木だけが画面いっぱいに広がり、内訳が見やすくなります。
- 気になるメソッド名があれば検索して、グラフ全体で光らせます。同じメソッドがあちこちに散っていても、合計の幅で重さが分かります。
- 絵が読みにくければ Top タブ(フラットな表)を見ます。メソッドが self と累積の重み順に並ぶので、上から潰していけます。
まず幅で候補を絞り、ズーム、検索、Top タブの順に情報量を増やします。
6.5 回帰調査で使う diff モード
Check Run の diff リンクから開くと、viewer は diff モードになります。 回帰調査ではこのモードを使います。
このモードでは色に意味が出ます。 base と比べて占有率が増えたメソッドが赤、減ったメソッドが青、ほぼ変化のないものが中間色です。 そのため diff モードでは、一番広い箱ではなく一番赤い箱を探します。 そこが、この PR で重くなった場所です。
色と幅は次のように読み分けます。
- 広くて赤い:もともと重かった処理がさらに悪化したことを表します。最優先で見ます。
- 細いが真っ赤:新しく現れた、または割合が急増した処理です。新規の原因の可能性があります。
幅は head の占有率を、色は base からの増減を表します。
6.6 viewer の操作
- クリック:その箱にズームします(部分木を全幅表示)。
- 検索ボックス:名前で絞り込み、ハイライトします。
JSONなどで関連箇所をまとめて把握できます。 - Top タブ、Tags タブ:表で見たいときに使います。self と累積の重み順に並びます。
- Shift+クリック:メソッドを pin します。スナップショット間での占有率の推移をスパークラインで表示します。
- j / k:新しいスナップショット、古いスナップショットへ移動します。
6.7 この絵が表すのは時間
prperf のフレームグラフは、時間(CPU の重み)の絵です。 アロケーション数や GC 回数は、summary の数字(Check Run の表)で見ます。 alloc が増えた回帰では、フレームグラフで時間が増えた(赤くなった)メソッドを手がかりに、そこで余計にオブジェクトを作っていないかを当たります。
6.8 読み方の要点
- 幅がコストを表し、高さは無視します。
- 上のほうの幅広な箱が、時間の使いどころです。
- 横方向は順番ではなく、単体表示の色には意味がありません。
- diff モードでは赤い箱を探します。そこが今回悪くなった場所です。
通常表示では幅を、diff モードでは色を優先して読みます。
prperf Manual
prperf is a thin GitHub App that checks each PR for performance regressions. Measurement runs inside your CI via the open-source Ruby sampling profiler rperf; prperf just compares the base (e.g. main) against this PR and reports on the PR. If you know Codecov for test coverage, this is that, for performance.
Open a PR and the Check Run shows numbers like:
2,001ms → 2,140ms (+7%) · alloc 48,741 → 59,950 (+23%) · GC 4 → 7
The next chapter, “What prperf is,” gives the overview.
A tour of prperf
Setup
- Install the GitHub App.
- Provide a benchmark. Here we’ll measure boot time with
bin/rails runner "". - Add a workflow that runs it, triggered on both
push(the default branch) andpull_request.
# .github/workflows/prperf.yml
name: prperf
on:
push:
branches: [main, master] # records the base (default branch; list both so main or master works)
pull_request: # compared against the base
jobs:
bench:
runs-on: ubuntu-latest
permissions: { contents: read, id-token: write }
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with: { bundler-cache: true }
- uses: rperf-dev/prperf-action@v1
with:
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- bin/rails runner "" # ← your measurement command (step 2)
Options like threshold alerts, multiple benchmarks (benchmark), comment control (comment), and run count (count, default 3, median) are available too (see “Setup”).
Results
On each PR, the result shows up right in the PR’s Checks (a summary compared against the base). A comment is posted only when a threshold is exceeded, and the flamegraph diff shows which method got heavier (see “Reading results”).
Every PR and push also records a measurement, so you can browse the history over time at prperf.atdot.net.
prperf never blocks CI and needs no secrets. PRs from forks can’t be measured, and during the free beta only public repositories are supported.
Contents
1. What prperf is
First, let’s be clear about what prperf is.
1.1 In one line
prperf is a thin GitHub App that automatically checks each PR for performance regressions and reports the result on the PR. Measurement happens inside your CI with the open-source Ruby profiler rperf; prperf itself just compares the base branch’s latest measurement (usually main) against this PR’s, and reports.
We call that base-branch baseline base and the PR side head — the same terms GitHub uses for pull requests. The rest of this manual uses them.
If you know Codecov for test coverage, prperf is that, for performance.
rperf is a time (CPU) sampling profiler — at heart a flamegraph of where time went. prperf pulls run time, GC, and allocations out of that profile and compares base vs head. Open a PR and the Check Run shows a summary like:
2,001ms → 2,140ms (+7%) · alloc 48,741 → 59,950 (+23%) · GC 4 → 7
prperf lets you notice “how performance changed in this commit” at the PR stage, before it merges.
1.2 What it does / doesn’t do
It does:
- Show the base→head performance delta (allocations, GC, time) on the Check Run for every PR
- Comment on the PR only when a threshold is exceeded (sticky, quiet notifications)
- Visualize which method got heavier with a flamegraph diff
It does not:
- It is not production monitoring. It complements Datadog / Grafana rather than replacing them (those watch production; prperf catches regressions at the PR stage).
- It never fails your CI. The verdict is informational; the Check’s conclusion is always success.
- It never runs your code on the server. Measurement happens inside your CI, and prperf only receives the result (the profile) and compares it. That is what makes it a “thin” App — light on both security and cost.
1.3 The big picture
Your CI (GitHub Actions)
└─ prperf-action
├─ measures your benchmark N times with rperf
└─ uploads the profiles (.json.gz) to the prperf server
│ (authenticated with the GitHub OIDC token — no secrets)
▼
prperf server
├─ compares base vs PR
└─ reports on the Check Run / PR comment
1.4 The user experience
- Install the GitHub App on your repository
- Add a few lines of the provided GitHub Action to your workflow
- Open a PR and the result appears on the Check and a PR comment
1.5 Why it’s trustworthy (the design ideas)
- The verdict leans on deterministic metrics. What rperf measures is mainly time (the flamegraph), but CI wall time swings ±10–20%. So for the verdict prperf weights the allocation and GC counts, which don’t (time stays informational). The result still shows time, GC, allocations, and the flamegraph.
- No secrets. Authentication is the GitHub Actions OIDC token — no API keys to issue or manage.
- Quiet notifications. The Check Run is a permanent home (zero notifications); a comment appears only on a threshold breach, one per PR, edited in place.
- You see “why.” The flamegraph diff pinpoints the method that got heavier.
1.6 Who it’s for
prperf suits authors of public gems and libraries who want to stop performance regressions at the PR. A dependency bump or a refactor can quietly add allocations or slow the boot; prperf catches it before the PR merges.
It also suits teams whose private apps care about performance — Rails apps where speed drives UX or revenue. You catch a heavy change in the same place you review it, before it reaches production.
Pricing: public repositories are free, private repositories are on a paid plan (currently public-only during the free beta).
1.7 How to read this manual
- Setup — installing and adding the workflows
- Writing a benchmark / the Rails quickstart — what to measure
- Reading the results / Reading a flamegraph — interpreting the output
Next, head to Setup.
2. Setup
Getting prperf running takes about 10–15 minutes. For an overview of the service, see “What prperf is.”
There are three things to do:
- Install the GitHub App
- Provide a benchmark
- Add a workflow that runs it (triggered on both PRs and pushes to the default branch)
2.1 Prerequisites
- rperf 0.10 or newer in your Gemfile. prperf uses the
meta/summaryembedded in the profile; with an older rperf the action stops with a clear error. - A benchmark command to measure (see below).
- A public repository. Private repositories require a paid plan (currently public-only during the free beta).
2.2 Install the GitHub App
Install the prperf GitHub App on your repository from its App page. This lets prperf write the Check Run and PR comments for that repository.
2.3 Provide a benchmark
What you measure determines the range of regressions you can catch. A good benchmark is deterministic, runs the path you care about, and does enough work to be stable. What and how to measure depends on your project, so the how-to and per-project examples are in “Writing a benchmark.”
For this guide we measure one concrete example: a Rails app’s boot. The
benchmark is just bin/rails runner "" — it boots the app and runs an empty
script, so there’s no benchmark file to write. The next section wraps it in
rperf and puts it in the workflow.
2.4 Add the workflow
Add a workflow that runs your benchmark. prperf compares the PR
head against the latest snapshot of the base branch. Trigger the workflow
on both push (the default branch) and pull_request: the push records the
base, and the PR is compared against it (prperf tells them apart from the OIDC
token’s ref). List both main and master for the branch so it works either
way — only your default branch exists, so only that one fires. Until the default
branch has been pushed once, there’s nothing to compare against: “No base
snapshot found — showing this run’s numbers only.”
# .github/workflows/prperf.yml
name: prperf
on:
push:
branches: [ main, master ] # records the base (your default branch)
pull_request: # compared against the base
jobs:
bench:
runs-on: ubuntu-latest
permissions:
contents: read # for checkout
id-token: write # required for OIDC upload
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: rperf-dev/prperf-action@v1
with:
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- bin/rails runner ""
In run:, wrap the command you want to measure in rperf record; it must write
at least one profile. Point its output at the action-provided $PRPERF_DIR with
--snapshot-dir "$PRPERF_DIR".
You must include permissions: id-token: write. Without it there is no
OIDC token and the upload cannot happen. contents: read lets
actions/checkout fetch the repository; once you set permissions:, anything
you don’t list defaults to none, so both are spelled out.
2.5 Thresholds and comments (optional)
A threshold is what gives you a ⚠️ on the Check Run when something regresses, and
where you draw the line — how much of an increase counts — is yours to set, per
metric. Concretely, it caps how much a metric (allocations, GC, time, etc.) may
increase from base to head; crossing it adds the ⚠️ and, per comment, a PR
comment. Thresholds are optional: without them the Check Run still shows
numbers, but no ⚠️ and no comment. Add them only when you want to be warned on a
regression.
All configuration lives in the workflow — there is no separate config file.
Write the global defaults once in the job’s env, and override per
benchmark if needed.
jobs:
bench:
runs-on: ubuntu-latest
permissions: { contents: read, id-token: write }
env:
PRPERF_DEFAULT_THRESHOLDS: | # applies to every benchmark
alloc: "+10%"
total_ms: "+20%"
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with: { bundler-cache: true }
- uses: rperf-dev/prperf-action@v1
with:
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- bin/rails runner ""
Threshold keys, with a recommended starting value for each (prperf has no built-in threshold — they take effect only once you set them):
| Key | Recommended default | Meaning |
|---|---|---|
alloc |
"+10%" |
Allocation increase. Can also be absolute, e.g. "+5000" |
gc_count |
"+2" |
GC count (minor+major) increase |
total_ms |
"+20%" |
Wall-time increase. Noisy, so use relative (%) |
cpu_ms |
"+15%" |
CPU-time increase |
method |
(none) | When a named method’s self-time share exceeds the given %. E.g. { "JSON.generate": "15%" } |
- Summary values are
"+N%"(relative) or"+N"(absolute); method values are"N%". - Invalid values are ignored, with one warning line on the Check Run (CI is never failed).
- Relative thresholds (
+10%) generalize cleanly across benchmarks. Absolute and method thresholds mean different things per benchmark, so override them per benchmark only when needed.
Comment behavior is controlled by the comment input (default on_threshold):
| Value | Behavior |
|---|---|
on_threshold |
Comment only when a threshold is exceeded (default) |
always |
Comment every time |
never |
Never comment (Check Run only) |
There is one comment per PR, and each push edits the same comment, so notifications stay at one.
2.6 Action inputs
| Input | Default | Description |
|---|---|---|
run |
(required) | Measurement command; must emit at least one .json.gz |
prepare_run |
"" |
One-time setup before measuring (generate fixtures, seed, etc.); not measured |
count |
3 |
Number of runs; the server compares the median |
benchmark |
default |
Benchmark series name; one commit can carry several, compared independently |
thresholds |
"" |
Thresholds for this benchmark (overrides the global defaults per key) |
comment |
on_threshold |
Comment behavior |
server |
https://prperf.atdot.net |
prperf server (replaceable) |
upload |
true |
Set false to measure without uploading |
2.7 Multiple benchmarks
You can measure one commit with several benchmarks — use one step per
benchmark with a distinct benchmark name. The server compares each against
its own base and shows them all in one Check Run.
- uses: rperf-dev/prperf-action@v1
with:
benchmark: boot
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- bin/rails runner ""
- uses: rperf-dev/prperf-action@v1
with:
benchmark: render
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- ruby bench/render.rb
Use the same benchmark names on the PR and push-to-default-branch triggers so each series has a baseline.
2.8 Verify it works
- Push to main first → the workflow runs on the push and the base snapshot reaches the server.
- Open a PR → the workflow runs on the PR; numbers on the Check Run mean success.
- A link to the uploaded result also appears in each job’s Summary.
2.9 Limitations
- PRs from forks cannot upload. GitHub does not grant
id-token: writeto fork-triggered workflows, so no OIDC token is available. Same-repository branch PRs work normally. - Upload problems (plan limits, rate limits, server errors) are warnings only; the step still succeeds. Only the measurement command itself failing fails the step.
- During the free beta, public repositories only. Private repositories are coming with paid plans.
3. Writing a benchmark
The numbers prperf reports are decided almost entirely by what you measure. Benchmark design is the part of this chapter that most shapes the result, and the part that takes the most effort.
3.1 What a benchmark is
To prperf, a benchmark is the command you pass to run:. Usually it’s a small
Ruby script (for example bench/main.rb) wrapped in rperf record:
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- ruby bench/main.rb
Put rperf in your Gemfile (0.10 or newer, so bundle exec rperf resolves). The
action runs this count times (default 3) and the server compares the median
against base. What you write is the body of bench/main.rb — a script that does
a representative chunk of work.
3.2 What makes a good benchmark
A good benchmark satisfies three things.
- It exercises the code you care about. A PR that doesn’t touch that code leaves the numbers unchanged.
- It is deterministic (does exactly the same work every time). Otherwise alloc and GC drift and you get warnings that aren’t regressions.
- It does a fixed amount of work. If it finishes in an instant it collects few samples and the result is unstable.
The axis of regression judgement is the allocation count. When a benchmark is deterministic, the allocation count is stable to the single object across PRs, so even a small increase is caught. GC counts are deterministic and easy to count too, so they are shown alongside. A benchmark that drifts loses that stability.
3.3 How to write one
The basic shape of bench/main.rb is: build a fixed input once, warm up, then
repeat the real loop enough times.
# bench/main.rb
require "json"
require_relative "../config/environment" # if needed (Rails, etc.)
# 1) Build the fixed input once (no randomness, time, or network)
DATA = { "users" => Array.new(100) { |i| { "id" => i, "name" => "user#{i}" } } }
# 2) Warm up (exclude one-time lazy loading / initialization from the measurement)
JSON.generate(DATA)
# 3) The real thing: repeat enough times
5_000.times do
JSON.generate(DATA)
end
Fix the input; don’t let it depend on rand, Time.now, a DB, an external API,
or filesystem enumeration order. If you truly need randomness, pin it with
srand(42). Warm up so that one-time work (autoload, constant init, lazy
loading) stays out of the measurement. Tune the count (5,000 here) so the whole
run takes a few hundred ms to a few seconds: too short is unstable, too long
slows CI down.
3.4 Check that it’s deterministic
Before wiring it into CI, run it two or three times locally and confirm the
allocation and GC counts are identical each time. rperf stat prints the
summary to stderr.
bundle exec rperf stat -- ruby bench/main.rb
bundle exec rperf stat -- ruby bench/main.rb
If allocated_objects and the GC counts match across runs, the benchmark is
deterministic. If they drift, eliminate the causes one at a time.
- No
randorSecureRandom(pin withsrandif you must) - The result doesn’t depend on
Time.noworDate.today - No network or external services
- No dependence on changing DB state (use fixed in-memory data, fixtures, or a fixed seed)
- No dependence on file enumeration order (
Dir.globorder, etc.) - The input size is the same every run
To get a sense of the cause, look inside with the flamegraph.
bundle exec rperf record -o out.json.gz -- ruby bench/main.rb
bundle exec rperf report out.json.gz # opens the viewer
Once you’ve pinned down the nondeterminism, confirm the match with rperf stat
again before putting it into CI.
3.5 Preparation (optional)
If you have setup that should run once before the benchmark, put it in
prepare_run:. Generating fixtures, seeding a DB, and building assets all
qualify. It runs once before the measurement and is not included in it.
- uses: rperf-dev/prperf-action@v1
with:
prepare_run: bin/rails db:prepare db:seed # once, before measuring
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- ruby bench/request.rb
A failure here fails the step. Use a fixed seed or input so each run starts from the same state.
3.6 Per-project examples
Use the workflow from “Setup” as is, and just point run: at each
bench/*.rb. Only benchmarks that need preparation (generating fixtures,
seeding a DB, building assets) add a prepare_run:.
3.6.1 gem / library
Call the public API N times on deterministic, fixed input. With the input and call count fixed, you measure only regressions in the public API.
# bench/main.rb
require "your_gem"
# Build the fixed input deterministically (no randomness, time, or network)
DATA = { "items" => Array.new(200) { |i| { "id" => i, "name" => "item-#{i}" } } }
YourGem.encode(DATA) # warm up
5_000.times { YourGem.encode(DATA) }
Change the require, the fixed input DATA, the API you call, and the count.
You can reuse an existing benchmark/ script (benchmark-ips and the like), but a
time-based loop varies its iteration count and makes alloc drift, so switch to a
fixed-count loop.
3.6.2 Sinatra / Rack apps
For any Rack app, send one request through the full stack N times.
# bench/request.rb
require_relative "../app" # load your Sinatra/Rack app
require "rack/mock"
app = Sinatra::Application # classic style. Modular: app = MyApp
PATH = ENV.fetch("BENCH_PATH", "/") # change to the path you want to measure
make = -> { Rack::MockRequest.env_for(PATH, "HTTP_HOST" => "localhost") }
pump = ->(r) { b = r[2]; b.each { |_| }; b.close if b.respond_to?(:close) }
3.times { pump.call(app.call(make.call)) } # warm up
2_000.times { pump.call(app.call(make.call)) }
Set run: to ruby bench/request.rb. Change the load line, app (the object
you run in config.ru), PATH, and the count. If the request reads a DB, add
a seed to the preparation and a postgres service to the workflow (see the “Rails
quickstart”).
3.6.3 CLI / plain Ruby
Calling the entry point in-process N times with fixed arguments avoids startup cost and external state, and keeps samples stable.
# bench/main.rb
require_relative "../lib/my_cli"
ARGS = %w[build --format json] # fixed arguments
200.times { MyCli.run(ARGS) } # call your entry point
To measure the executable itself, confirm it does enough work and then pass it
to run: directly. A single short startup collects few samples and is unstable,
so loop over it, or use the in-process loop above.
3.6.4 Rails apps
Rails is covered in the next chapter, the “Rails quickstart” — boot, endpoints, typical queries, and jobs are all there. Roda and grape are Rack apps, so measure them as in “Sinatra / Rack”; Hanami follows the same idea as Rails.
3.7 Anti-patterns
The following ways of measuring move the numbers for reasons unrelated to the PR’s changes, so avoid them.
- Measuring the test suite as is (
rperf record -- rspec). Adding tests in a PR inflates alloc, and you can’t tell that apart from a regression. - Depending on randomness, time, or network. It drifts every run and causes false positives.
- Too short. Time drifts and alloc is too small for a delta to show.
- Measuring a path you barely care about. The PR never touches it, so it always reads “no change.”
- Using real external dependencies (API or DB). It drifts with the network.
A giant everything-in-one benchmark is also worth avoiding, because it’s hard to tell what regressed. Split by concern and measure several benchmarks for one commit, and you can trace the regressed code through the Check Run and the diff (for how to split, see “Multiple benchmarks” in “Setup”).
3.8 How this ties to thresholds
A deterministic benchmark lets you set tight relative thresholds (for example
alloc: "+5%") without false positives. A benchmark that drifts forces you to
loosen the thresholds, and the signal weakens. Start with one benchmark that
measures, deterministically, the path your PRs are most likely to touch, and get
to a state where base and head show a difference.
4. Rails quickstart
Before agonizing over “what to measure,” here is an almost copy-paste starting point for Rails, in two steps. Step ① gives you the “numbers appear” experience in 30 seconds; go to ② when you want more.
4.1 Measure boot first (no extra files)
bin/rails runner "" boots the app and exits doing nothing, so it measures
boot itself. It catches added gems, heavier initializers, and autoload changes;
it is deterministic and needs no extra files and no database.
Paste this as .github/workflows/prperf.yml:
name: prperf
on:
push:
branches: [main, master] # records the base (default branch)
pull_request: # compared against the base
jobs:
bench:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- uses: rperf-dev/prperf-action@v1
with:
benchmark: boot
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- bin/rails runner ""
The single workflow’s push records the base, so that alone gives you boot alloc/GC compared on every PR.
Make sure rperf 0.10 or newer is in your Gemfile.
4.2 Measure one request (the full version)
This measures the allocations and GC along an endpoint’s request-handling path. Paste three files.
4.2.1 A measurement environment config/environments/benchmark.rb
A production-like but CI-friendly dedicated environment.
# config/environments/benchmark.rb
require_relative "production"
Rails.application.configure do
config.eager_load = true # load all code, like production
config.force_ssl = false # avoid an SSL redirect measuring nothing
config.hosts.clear # drop host restrictions (for the benchmark)
config.require_master_key = false # boot without the master key
config.log_level = :warn
config.consider_all_requests_local = false
end
4.2.2 The benchmark bench/request.rb
# bench/request.rb — one request through the full stack, N times
require_relative "../config/environment"
require "rack/mock"
PATH = ENV.fetch("BENCH_PATH", "/api/health") # ← change to the endpoint you care about
app = Rails.application
build_env = -> { Rack::MockRequest.env_for(PATH, "HTTP_HOST" => "localhost") }
consume = lambda do |result|
body = result[2]
body.each { |_| } # consume the body so rendering is measured
body.close if body.respond_to?(:close)
end
# warm up (autoload, template compilation, connection setup)
3.times { consume.call(app.call(build_env.call)) }
1_000.times { consume.call(app.call(build_env.call)) }
4.2.3 The workflow .github/workflows/prperf.yml
name: prperf
on:
push:
branches: [main, master] # records the base (default branch)
pull_request: # compared against the base
jobs:
bench:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
services:
postgres: # drop services and db:prepare if you don't use a DB
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports: [ "5432:5432" ]
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
env:
RAILS_ENV: benchmark
SECRET_KEY_BASE: dummy-for-benchmark
DATABASE_URL: postgres://postgres:postgres@localhost:5432/app_benchmark
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bin/rails db:prepare db:seed # only if the request hits the DB; seed fixed data
- uses: rperf-dev/prperf-action@v1
with:
benchmark: boot
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- bin/rails runner ""
- uses: rperf-dev/prperf-action@v1
with:
benchmark: request
run: bundle exec rperf record --snapshot-dir "$PRPERF_DIR" -- ruby bench/request.rb
The single workflow records the base on push to the default branch.
4.3 The only things you change
PATH(bench/request.rb) — the endpoint to measure. A JSON/API endpoint is easiest (no asset precompilation, less likely to be auth-gated).db:seed— if the request reads the DB, provide fixed seed data; if not, delete the postgres service and the db line.- The count (1,000) — tune so the whole run is a few hundred ms to a few seconds.
4.4 When it doesn’t work
- Empty results / all redirects —
force_sslis issuing 301s, or auth is blocking you. Thebenchmarkenvironment already setsforce_ssl = false. For an auth-gated path, pick a public endpoint or build a signed-in env inbench/request.rb. - Asset-related errors — an asset helper in the view. The quick fix is to measure an API/JSON endpoint.
- Numbers jiggle every run — check that the seed is fixed and the request
has no time/randomness (see the “Writing a benchmark” checklist). Locally, run
RAILS_ENV=benchmark bundle exec rperf stat -- ruby bench/request.rbtwice and confirm alloc/GC match.
5. Reading the results
prperf reports in three places: the Check Run (numbers), the PR comment (when a threshold is exceeded), and the flamegraph viewer (to dig in). Here is how to read each.
5.1 The Check Run
A Check Run named prperf appears in the PR’s Checks. Its conclusion is
always success — prperf never fails your CI. The verdict is informational.
5.1.1 Title
2,001ms → 2,140ms (+7%) · alloc 48,741 → 59,950 (+23%) · GC 4 → 7
The key base → head metrics. If any metric exceeded its threshold, a ⚠️ is
prepended.
5.1.2 Summary (body)
- base / head metric table — alloc, GC (minor/major), GC time, total ms, CPU ms, max RSS. Rows over threshold are bold.
- Top 10 method diff rows —
base self% → head self% → Δpt, so you can see which methods grew in share. - A diff link to the viewer — the way into the flamegraph.
- When there is no base, it shows “No base snapshot found — showing this run’s numbers only.”
5.1.3 Which numbers to trust
- Allocation and GC counts are deterministic — they barely move even when the CI runner changes. These are the primary signal for regressions.
- Time (total_ms / cpu_ms) is noisy — CI wall time swings ±10–20%. We
compare the median of
count(default 3) runs, but treat time as meaningful only when it moves a lot.
5.2 The PR comment (sticky)
When a threshold is exceeded (subject to the comment setting), one comment is
posted on the PR.
- One comment per PR. Each push edits the same comment, so notifications stay at one and the thread isn’t spammed.
- Exceeded metrics are listed like
⚠️ **alloc** 48,741 → 59,950 (threshold +10%). With multiple benchmarks the benchmark name is included.
With comment: never there is no comment, only the Check Run; with always it
comments every time even without an exceedance.
5.3 The flamegraph viewer
Open it from the Check Run’s diff link or a shareable URL
(/view/<repo>/<sha>). It is rperf’s viewer, so the controls are the same.
- Flamegraph — width is time (weight); wider is heavier.
- Top tab (table) — the same data as a table of methods sorted by self and cumulative weight; handy when the flamegraph is hard to read or you want to tackle the heaviest first. The
Tagstab breaks it down by rperf label (tag). - Diff mode — colors the difference between base and head: methods whose share increased are red, decreased are blue. The Check Run’s diff link opens in this mode.
- Time-travel sidebar — past snapshots of that benchmark series are listed,
so you can walk main’s trend.
j/kmove to newer / older. - Pin a method — Shift+click a method to pin it; a sparkline shows its share across snapshots.
Share the permanent link (/view/<repo>/<sha>). Under the hood the viewer
fetches a short-lived signed URL on demand, so revoked access takes effect
within ten minutes.
With multiple benchmarks, open
/view/<repo>/diff?base=<sha>&head=<sha>&bench=<name> to diff a particular
benchmark. The viewer sidebar is scoped to one benchmark series, so boot and
endpoint snapshots never interleave.
5.4 The dashboard
/(top) — the marketing/explanation page./me(after sign-in) — a list of repositories you can see, each linking to the latest result per benchmark. Public repositories appear for everyone; private ones only for people with GitHub read access.
Sign-in is GitHub OAuth; there is no prperf-specific account. Authorization always follows GitHub permissions (public snapshots are viewable without signing in).
5.5 What to do when a regression is flagged
- From the ⚠️ title and metric table, see what grew (alloc / GC / time / a specific method) and by how much.
- Open the flamegraph via the diff link and find the methods that turned red.
- For an allocation increase, look for where extra objects are created; for GC, that’s the consequence; for time, suspect CI noise first.
- Push a fix and the same Check Run and sticky comment update in place.
5.6 Common states
- “No base snapshot found” — there is no latest base-branch snapshot yet. The workflow must have run on a push at least once on a commit that is an ancestor of the PR.
- Always “no change” — the PR simply doesn’t touch the path the benchmark exercises. Check that the benchmark covers what you care about.
- Numbers swing every time — the benchmark has nondeterminism (randomness, time, I/O). Make it deterministic, or loosen / drop the time thresholds.
6. Reading a flamegraph
A flamegraph is a picture of where your program spent its time. It takes a little practice to read, so this chapter starts from zero and goes slowly.
6.1 What the picture actually is
While measuring, prperf (rperf) takes an enormous number of snapshots of your program and records “what was running at that moment” (the call stack). Stacking thousands of those photos and tallying them gives the flamegraph.
- One box = one method (function).
- The width of a box = the share of time spent in that method (and whatever it called) — i.e. how many photos caught it running. Wider = heavier.
- The vertical stacking = call depth. The bottom is the entry point; the higher you go, the closer to the leaf (what was actually executing).
A picture beats words. Here is an example (bottom = entry, top = leaf):
┌──────────┐
│ String#* │ ← leaf: actually on the CPU (wider = heavier)
┌───┴──────────┴───┐
│ JSON.generate │ ← its caller
┌──┴──────────────────┴────────────┐
│ Integer#times │ ← a loop: wide, but it just calls its children
┌──┴──────────────────────────────────┴──┐
│ <main> │ ← the whole program; bottom = root, always ~100% wide
└────────────────────────────────────────┘
What this tells you:
- The bottom
<main>is 100% wide. That’s the whole program, so it is expected — don’t be alarmed by it. - The real cost is in the wide boxes near the top (here
String#*). That is where the CPU was actually pinned.
6.2 The two rules that matter most
- Only look at width. Width = cost. Read each box as “if I could make this zero, that’s how much I’d save.”
- Ignore height. A tall tower (deep nesting) is cheap if it’s thin. A short box is heavy if it’s wide. Hunt for wide plateaus, not tall towers.
6.3 Common misreadings
- Left-to-right is NOT time order. It is not “this ran, then that”; boxes are just sorted (e.g. by name). Don’t read it as a timeline.
- Color means nothing in the single view. It only separates boxes; “red” is not “hot.” (Color does mean something in diff mode — see below.)
- A 100%-wide box at the bottom is normal. It just represents everything.
6.4 A reading recipe
- Find the wide boxes near the top — that’s where your time goes.
- Click to zoom into that box: its subtree fills the width, so the breakdown is easier to see.
- If a method name interests you, search for it to highlight it across the whole graph — even if it’s scattered, the combined width reveals the cost.
- Prefer a table? Use the Top tab: methods listed by self / cumulative weight. Work down from the top.
6.5 In prperf you mostly use “diff mode”
Opening the diff link from the Check Run puts the viewer in diff mode — this is the heart of regression hunting.
- Now color has meaning. Compared to base, methods whose share increased are red, decreased are blue, and near-unchanged are neutral.
- So in diff mode you look for the reddest box, not the widest. That is “what got heavier in this PR.”
- How to read it:
- Wide and red = something already significant that got worse. Top priority.
- Narrow but bright red = newly appeared, or grew sharply in share. A likely new culprit.
- Remember: width = head’s share, color = the change from base.
6.6 Handy viewer controls
- Click — zoom into a box (its subtree fills the width).
- Search box — filter/highlight by name; type
JSONto grasp all related spots at once. - Top / Tags tabs — for table lovers; sorted by self / cumulative weight.
- Shift+click — pin a method; a sparkline shows its share across snapshots (time travel).
- j / k — move to newer / older snapshots.
6.7 Note: this is a picture of time
prperf’s flamegraph is a picture of time (CPU weight). Allocation and GC counts live in the summary numbers (the Check Run table). For an “alloc went up” regression, use the flamegraph to find the method whose time grew (turned red), then check whether that path is creating extra objects.
6.8 Summary (all you really need)
- Width = cost; ignore height.
- Wide near the top = where your time goes.
- Left-to-right is not order; single-view color is meaningless.
- In prperf’s diff, find the red box — that’s what got worse this time.