- パフォーマンスに関する文書
- ワークフロー
- ツール
- ベンチマーク
- Stackprofを使ったプロファイリング
- RSpec プロファイリング
- メモリの最適化
- 変更のインポート
- スローオペレーションとSidekiq
- Gitオペレーション
- キャッシュ
- 文字列の凍結
- Banzaiパイプラインとフィルタ
- ファイルやその他のデータソースからの読み込み
- アンチパターン
- 数百万行のデータベースをシードする方法
パフォーマンス・ガイドライン
このドキュメントでは、GitLabの良好で一貫したパフォーマンスを確保するために従うべき様々なガイドラインについて説明します。パフォーマンス関連のページに移動するには、以下の索引セクションを参照してください。
パフォーマンスに関する文書
- 一般
- バックエンド
- フロントエンド
- QAです:
- モニタリングと概要
- 自主管理で顧客重視:
ワークフロー
パフォーマンス問題を解決するプロセスは、おおよそ以下の通りです:
- どこかにイシューが開かれていることを確認し(例えば GitLab CE のイシュー・トラッカー)、なければ作成します。例として#15607をご覧ください。
- GitLab.com のような本番環境で、コードのパフォーマンスを測定しましょう (後述のツールのセクションを参照ください)。パフォーマンスは、_少なくとも_24 時間にわたって測定してください。
- ステップ 1 で述べたイシューに、測定期間から得られた知見 (グラフのスクリーンショット、タイミングなど) を追加してください。
- 問題を解決してください。
- マージリクエストを作成し、「パフォーマンス」ラベルを割り当て、パフォーマンスレビュープロセスに従ってください。
- 変更がデプロイされたら、少なくとも 24 時間にわたって_再度_測定を行い、変更が本番環境に何らかの影響を与えるかどうかを確認してください。
- 完了するまで繰り返します。
タイムを伝えるときは、必ず
- 95パーセンタイル
- 99パーセンタイル
- 平均値
グラフのスクリーンショットを提供するときは、X軸とY軸の両方と凡例がはっきりと見えるようにしてください。GitLab.com独自のモニタリングツールにアクセスできる場合は、関連するグラフやダッシュボードへのリンクも提供してください。
ツール
GitLabはパフォーマンスと可用性を向上させるビルトインツールを提供しています:
- プロファイリング。
- 分散トレース
- GitLabパフォーマンスモニタリング。
-
N+1
のリグレッションを防ぐためのQueryRecoder。 - 障害シナリオをテストするためのChaos エンドポイント。主に可用性をテストするためのものです。
- サービス計測:サービスの実行を計測し、ログに記録します。
GitLabチームのメンバーは、dashboards.gitlab.net
にあるGitLab.comのパフォーマンスモニタリングシステムを利用することができます。このためには、@gitlab.com
のメールアドレスを使ってサインインする必要があります。GitLabチームメンバー以外の方は、ご自身でPrometheusとGrafanaスタックをセットアップすることをお勧めします。
ベンチマーク
ベンチマークはほとんどの場合役に立ちません。ベンチマークは通常、小さなコードの断片を単独でテストするだけで、多くの場合、最良のシナリオを測定するだけです。その上、(Gemのような)ライブラリのベンチマークは、そのライブラリに偏りがちです。結局のところ、作成者が競合他社よりも性能が悪いことを示すベンチマークを公開するメリットはほとんどありません。
ベンチマークが本当に役に立つのは、変更の影響を大まかに (「大まかに」を強調して) 理解する必要があるときだけです。たとえば、あるメソッドが遅い場合、ベンチマークを使用することで、そのメソッドのパフォーマンスに変更が影響するかどうかを確認することができます。しかし、ベンチマークがあなたの変更によってパフォーマンスが向上したことを示していても、本番環境でもパフォーマンスが向上するという保証はありません。
ベンチマークを書く際には、ほとんどの場合benchmark-ips を使うべきです。標準ライブラリに付属しているRubyのBenchmark
モジュールは、1回の繰り返し(Benchmark.bm
を使用した場合)か2回の繰り返し(Benchmark.bmbm
を使用した場合)しか実行しないため、ほとんど役に立ちません。このように少ない反復を実行するということは、バックグラウンドでストリーミングしている動画などの外的要因によって、ベンチマークの統計が簡単に歪められてしまうことを意味します。
Benchmark
モジュールのもう1つの問題点は、反復ではなくタイミングを表示することです。つまり、コードの一部が非常に短時間で完了する場合、特定の変更前後のタイミングを比較するのが非常に難しくなります。その結果、次のようなパターンが生じます:
Benchmark.bmbm(10) do |bench|
bench.report 'do something' do
100.times do
... work here ...
end
end
end
しかし、これは「意味のある統計を取るには、何回繰り返せばいいのか?
benchmark-ips
gemはこのような問題を解決してくれます。Benchmark
。
GitLab Gemfile にはbenchmark-memory
gem も含まれており、benchmark
やbenchmark-ips
と同様の働きをします。しかし、benchmark-memory
は代わりに、ベンチマーク中に割り当てられ保持されたメモリサイズ、オブジェクト、文字列を返します。
要するに
- インターネットで見つけたベンチマークを信用してはいけません。
- ベンチマークだけに基づいて主張するのではなく、必ず本番で測定して結果を確認してください。
- XがYよりN倍速いといっても、それが本番環境にどのような影響を与えるかわからなければ意味がありません。
- 本番環境は、(パフォーマンス監視システムが正しく設定されていない場合を除き)常に真実を伝える_唯一の_ベンチマークです。
- どうしてもベンチマークを書きたい場合は、Rubyの
Benchmark
モジュールの代わりにbenchmark-ips Gemを使用してください。
Stackprofを使ったプロファイリング
一定間隔でプロセスの状態のスナップショットを収集することで、プロファイリングはプロセスのどこに時間が費やされているかを見ることができます。GitLabにはStackprofgemが含まれており、CPUで実行されているコードを詳細にプロファイリングすることができます。
アプリケーションをプロファイリングするとパフォーマンスが変わることに注意しましょう。異なるプロファイリング戦略には異なるオーバーヘッドがあります。Stackprof はサンプリングプロファイラです。実行中のスレッドのスタックトレースを設定可能な頻度(たとえば 100 hz、つまり 1 秒あたり 100 スタック)でサンプリングします。このタイプのプロファイリングはオーバーヘッドがかなり低く(ゼロではありませんが)、一般に実運用では安全だと考えられています。
プロファイラは、たとえそれが代表的でない環境で実行されたとしても、開発中には非常に有用なツールになりえます。特に、あるメソッドが何度も実行されたり、実行に時間がかかったりしたからといって、必ずしも問題があるとは限りません。プロファイルはアプリケーションで何が起きているかをよりよく理解するために使えるツールです!
Stackprofでプロファイルを作成するには複数の方法があります。
コードブロックのラッピング
特定のコード・ブロックをプロファイリングするには、そのブロックをStackprof.run
呼び出しでラップします:
StackProf.run(mode: :wall, out: 'tmp/stackprof-profiling.dump') do
#...
end
これにより、読み込める.dump
ファイルが作成されます。利用可能なすべてのオプションについては、Stackprof のドキュメントを参照してください。
パフォーマンスバー
パフォーマンス・バーでは、Stackprof を使用してリクエストをプロファイリングし、その結果をSpeedscope フレームグラフに即座に出力するオプションがあります。
Stackprof による RSpec プロファイリング
spec からプロファイルを作成するには、問題のあるコードパスを実行する spec を特定 (または作成) し、bin/rspec-stackprof
ヘルパーなどを使用して実行します:
$ LIMIT=10 bin/rspec-stackprof spec/policies/project_policy_spec.rb
8/8 |====== 100 ======>| Time: 00:00:18
Finished in 18.19 seconds (files took 4.8 seconds to load)
8 examples, 0 failures
==================================
Mode: wall(1000)
Samples: 17033 (5.59% miss rate)
GC: 1901 (11.16%)
==================================
TOTAL (pct) SAMPLES (pct) FRAME
6000 (35.2%) 2566 (15.1%) Sprockets::Cache::FileStore#get
2018 (11.8%) 888 (5.2%) ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#exec_no_cache
1338 (7.9%) 640 (3.8%) ActiveRecord::ConnectionAdapters::PostgreSQL::DatabaseStatements#execute
3125 (18.3%) 394 (2.3%) Sprockets::Cache::FileStore#safe_open
913 (5.4%) 301 (1.8%) ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#exec_cache
288 (1.7%) 288 (1.7%) ActiveRecord::Attribute#initialize
246 (1.4%) 246 (1.4%) Sprockets::Cache::FileStore#safe_stat
295 (1.7%) 193 (1.1%) block (2 levels) in class_attribute
187 (1.1%) 187 (1.1%) block (4 levels) in class_attribute
RSpec
が通常取る引数を渡すことで、実行する仕様を制限できます。
本番環境でのStackprofの使用
Stackprofは本番ワークロードのプロファイリングにも使用できます。
Ruby プロセスの実運用プロファイリングを有効にするには、STACKPROF_ENABLED
環境変数をtrue
に設定します。
設定できるオプションは以下のとおりです:
-
STACKPROF_ENABLED
:SIGUSR2 シグナルの Stackprof シグナル・ハンドラを有効にします。デフォルトはfalse
です。 -
STACKPROF_MODE
:サンプリングモードを参照してください。デフォルトはcpu
。 -
STACKPROF_INTERVAL
:サンプリング間隔。単位セマンティクスはSTACKPROF_MODE
に依存。object
モードの場合、これはイベントごとの間隔で(nth
イベントごとにサンプリングされる)、デフォルトは100
です。cpu
などの他のモードでは、これは周波数間隔であり、デフォルトは10100
μs (99 hz)。 -
STACKPROF_FILE_PREFIX
:プロファイルが保存されるファイルパスのプレフィックス。デフォルトは$TMPDIR
(多くの場合/tmp
に対応)。 -
STACKPROF_TIMEOUT_S
:プロファイリングのタイムアウト時間(秒)。この時間が経過すると、プロファイリングは自動的に停止します。デフォルトは30
。 -
STACKPROF_RAW
:生サンプルを収集するか、集合体のみを収集するかを指定します。生サンプルはフレームグラフの生成に必要ですが、メモリとディスクのオーバーヘッドが大きくなります。デフォルトはtrue
。
プロファイリングを有効にすると、SIGUSR2
Rubyプロセスにシグナルを SIGUSR2
送ることでプロファイリングを開始できます。SIGUSR2
プロセスはスタックのサンプリングを開始します。プロファイリングを停止するには SIGUSR2
、.または、タイムアウト後に自動的に停止します。
プロファイリングが停止すると、プロファイルは$STACKPROF_FILE_PREFIX/stackprof.$PID.$RAND.profile
でディスクに書き出されます。その後、「Stackprof プロファイルの読み取り」のセクションで説明するように、stackprof
コマンド・ライン・ツールを使用してさらに検査することができます。
現在サポートされているプロファイリング・ターゲットは以下のとおりです:
- Puma ワーカー
- Sidekiq
これはpkill -USR2 puma:
経由で可能です。:
はpuma
4.3.3.gitlab.2 ...
(マスタープロセス)とpuma: cluster worker 0: ...
(ワーカープロセス)を区別し、後者を選択します。
Sidekiqの場合、pkill
-USR2 bin/sidekiq-cluster
を経由して、sidekiq-cluster
プロセスにシグナルを送信できます。 は、すべてのSidekiqチルドレンにシグナルを転送します。また、特定のPIDを選択することもできます。
Stackprofプロファイルの読み取り
出力はデフォルトでSamples
列でソートされます。これは、メソッドが現在実行されているサンプルの数です。Total
列は、そのメソッド (またはそのメソッドが呼び出すメソッドのいずれか) が実行されたサンプル数を示します。
コールスタックをグラフィカルに表示するには
stackprof tmp/project_policy_spec.rb.dump --graphviz > project_policy_spec.dot
dot -Tsvg project_policy_spec.dot > project_policy_spec.svg
KCachegrindでプロファイルをロードします:
stackprof tmp/project_policy_spec.rb.dump --callgrind > project_policy_spec.callgrind
kcachegrind project_policy_spec.callgrind # Linux
qcachegrind project_policy_spec.callgrind # Mac
プロファイルを KCachegrind で読み込むには: 結果のフレーム・グラフを生成して表示することもできます。bin/rspec-stackprof
が作成したフレーム・グラフを表示するには、bin/rspec-stackprof
を実行する際にRAW
環境変数をtrue
に設定する必要があります。
出力ファイルのサイズによっては、生成に時間がかかる場合があります:
# Generate
stackprof --flamegraph tmp/group_member_policy_spec.rb.dump > group_member_policy_spec.flame
# View
stackprof --flamegraph-viewer=group_member_policy_spec.flame
フレームグラフをSVGファイルにエクスポートするには、Brendan GreggのFlameGraphツールを使用してください:
stackprof --stackcollapse /tmp/group_member_policy_spec.rb.dump | flamegraph.pl > flamegraph.svg
Speedscopeでフレームグラフを見ることもできます。パフォーマンスバーを使用するときや、コードブロックをプロファイリングするときに使用できます。このオプションはbin/rspec-stackprof
ではサポートされていません。
--method method_name
を使用すると、特定のメソッドをプロファイリングできます:
$ stackprof tmp/project_policy_spec.rb.dump --method access_allowed_to
ProjectPolicy#access_allowed_to? (/Users/royzwambag/work/gitlab-development-kit/gitlab/app/policies/project_policy.rb:793)
samples: 0 self (0.0%) / 578 total (0.7%)
callers:
397 ( 68.7%) block (2 levels) in <class:ProjectPolicy>
95 ( 16.4%) block in <class:ProjectPolicy>
86 ( 14.9%) block in <class:ProjectPolicy>
callees (578 total):
399 ( 69.0%) ProjectPolicy#team_access_level
141 ( 24.4%) Project::GeneratedAssociationMethods#project_feature
30 ( 5.2%) DeclarativePolicy::Base#can?
8 ( 1.4%) Featurable#access_level
code:
| 793 | def access_allowed_to?(feature)
141 (0.2%) | 794 | return false unless project.project_feature
| 795 |
8 (0.0%) | 796 | case project.project_feature.access_level(feature)
| 797 | when ProjectFeature::DISABLED
| 798 | false
| 799 | when ProjectFeature::PRIVATE
429 (0.5%) | 800 | can?(:read_all_resources) || team_access_level >= ProjectFeature.required_minimum_access_level(feature)
| 801 | else
Stackprof を使用して仕様のプロファイルを作成する場合、プロファイルにはテスト・スイートとアプリケーション・コードで実行された作業も含まれます。したがって、これらのプロファイルを使用して、遅いテストを調査することもできます。しかし、(この例のような)小規模な実行の場合は、テスト・スイートのセットアップにかかるコストのほうが大きくなる傾向があります。
RSpec プロファイリング
GitLab の開発環境にはrspec_profiling
gem も含まれており、これを使うと spec の実行時間のデータを収集することができます。これは、テストスイート自体のパフォーマンスを分析したり、ある spec のパフォーマンスが時間とともにどう変化したかを調べたりするのに便利です。
ローカル環境でプロファイリングを有効にするには、以下を実行してください:
export RSPEC_PROFILING=yes
rake rspec_profiling:install
これにより、tmp/rspec_profiling
に SQLite3 データベースが作成され、RSPEC_PROFILING
環境変数が設定された specs を実行するたびに統計情報が保存されます。
収集された結果のアドホックな調査は、対話型シェルで実行できます:
$ rake rspec_profiling:console
irb(main):001:0> results.count
=> 231
irb(main):002:0> results.last.attributes.keys
=> ["id", "commit", "date", "file", "line_number", "description", "time", "status", "exception", "query_count", "query_time", "request_count", "request_time", "created_at", "updated_at"]
irb(main):003:0> results.where(status: "passed").average(:time).to_s
=> "0.211340155844156"
これらの結果は、RSPEC_PROFILING_POSTGRES_URL
変数を設定することで PostgreSQL データベースに格納することもできます。これは、CI 環境でテストスイートを実行する際のプロファイリングに使用されます。
これらの結果は、gitlab.com
のデフォルトブランチで夜間スケジュール CI ジョブを実行する際にも保存されます。これらのプロファイリングデータの統計はオンラインで利用可能です。たとえば、どのテストが実行に一番時間がかかるか、あるいはどのテストが一番クエリを多く実行しているかを調べることができます。テストを最適化したり、コードのパフォーマンス上のイシューを特定したりするのに利用しましょう。
メモリの最適化
メモリの問題を突き止めるために、私たちはさまざまなテクニックを、しばしば組み合わせて使用することができます:
- コードをそのままにしておいて、プロファイラをそのコードに巻きつけます。
- リクエストとサービスのメモリ割り当てカウンタを使用します。
- 問題があると思われるコードのさまざまな部分を無効化/有効化しながら、プロセスのメモリ使用量を監視します。
メモリ割り当て
GitLabに同梱されているRubyには、メモリ割り当てをトレースするための特別なパッチが含まれています。このパッチはOmnibus,CNG,GitLab CI,GCKでデフォルトで利用可能で、GDKではさらに有効にすることができます。
このパッチは以下のメトリクスを提供し、指定されたコードパスのメモリ使用効率を理解しやすくします:
-
mem_total_bytes
: 既存のオブジェクトスロットに新しいオブジェクトが割り当てられたために消費されたバイト数と、ラージオブジェクトのために追加で割り当てられたメモリ(つまり、mem_bytes + slot_size * mem_objects
)。 -
mem_bytes
既存のオブジェクトスロットに収まらなかったオブジェクトのためにmalloc
が割り当てたバイト数。 -
mem_objects
: 割り当てられたオブジェクトの数。 -
mem_mallocs
malloc
の呼び出し回数。
割り当てられたオブジェクトとバイト数は、GCサイクルの発生頻度に影響します。オブジェクトの割り当てが少ないと、アプリケーションの応答性が大幅に向上します。
Webサーバーのリクエストでは、100k mem_objects
と100M mem_bytes
を超える割り当てを行わないことをお勧めします。GitLab.comで現在の使用量を見ることができます。
自作コードのメモリ圧迫のチェック
自分のコードを測定する方法は2つあります:
- メモリ割り当てカウンタを含む
api_json.log
,development_json.log
,sidekiq.log
をレビューしてください。 - 与えられたコードブロックに対して
Gitlab::Memory::Instrumentation.with_memory_allocations
を使用し、それをログに記録してください。 - Measuringモジュールの使用
{"time":"2021-02-15T11:20:40.821Z","severity":"INFO","duration_s":0.27412,"db_duration_s":0.05755,"view_duration_s":0.21657,"status":201,"method":"POST","path":"/api/v4/projects/user/1","mem_objects":86705,"mem_bytes":4277179,"mem_mallocs":22693,"correlation_id":"...}
さまざまな割り当て
mem_*
の値は、Ruby におけるオブジェクトとメモリの割り当て方法の異なる側面を表しています:
-
以下の例では、文字列はフリーズすることがあるので、
mem_objects
の1000
の周りを作成します。基礎となる文字列オブジェクトは変わりませんが、この文字列への1000個の参照を割り当てる必要があります:Gitlab::Memory::Instrumentation.with_memory_allocations do 1_000.times { '0123456789' } end => {:mem_objects=>1001, :mem_bytes=>0, :mem_mallocs=>0}
-
文字列は動的に生成されるため、次の例では
mem_objects
の1000
の周りを作成します。文字列は動的に作成されるため、以下の例では , の文字列が作成されます:Gitlab::Memory::Instrumentation.with_memory_allocations do s = '0' 1_000.times { s * 23 } end => {:mem_objects=>1002, :mem_bytes=>0, :mem_mallocs=>0}
-
文字列は動的に生成されるため、以下の例では
mem_objects
の1000
の周囲に作成されます。文字列はRubyの40バイトのスロットより大きいので、それぞれ追加のメモリが割り当てられます:Gitlab::Memory::Instrumentation.with_memory_allocations do s = '0' 1_000.times { s * 24 } end => {:mem_objects=>1002, :mem_bytes=>32000, :mem_mallocs=>1000}
-
以下の例では、40 kB 以上のデータを割り当て、1 回のメモリ割り当てのみを行います。既存のオブジェクトは、その後の繰り返しで再割り当て/リサイズされます:
Gitlab::Memory::Instrumentation.with_memory_allocations do str = '' append = '0123456789012345678901234567890123456789' # 40 bytes 1_000.times { str.concat(append) } end => {:mem_objects=>3, :mem_bytes=>49152, :mem_mallocs=>1}
-
次の例では、1k 個以上のオブジェクトを作成し、1k 個以上の割り当てを行い、そのたびにオブジェクトを変更します。この結果、多くのデータがコピーされ、多くのメモリ割り当てが行われます(
mem_bytes
カウンターで表されます)。これは文字列を追加する方法として非常に非効率的であることを示しています:Gitlab::Memory::Instrumentation.with_memory_allocations do str = '' append = '0123456789012345678901234567890123456789' # 40 bytes 1_000.times { str += append } end => {:mem_objects=>1003, :mem_bytes=>21968752, :mem_mallocs=>1000}
メモリ・プロファイラの使用
プロファイリングにはmemory_profiler
を使用します。
memory_profiler
gem はすでに GitLabGemfile
に存在しています。現在のURLのパフォーマンスバーでも利用できます。
あなたのコードで直接メモリプロファイラを使うには、require
を使って追加します:
require 'memory_profiler'
report = MemoryProfiler.report do
# Code you want to profile
end
output = File.open('/tmp/profile.txt','w')
report.pretty_print(output)
このレポートでは、保持メモリと割り当てメモリが、gem、ファイル、場所、クラスごとにグループ化されて表示されます。メモリプロファイラでは文字列の解析も行われ、文字列の割り当てと保持の頻度がわかります。
保持と割り当て
- 保持メモリ:コードブロックの実行によって保持される、長期間のメモリ使用とオブジェクト数。これはメモリとガベージコレクタに直接影響します。
- 割り当てメモリ:コードブロック中のすべてのオブジェクト割り当てとメモリ割り当て。メモリへの影響は少ないかもしれませんが、パフォーマンスへの影響は大きいです。割り当てるオブジェクトが多ければ多いほど、より多くの作業が行われ、アプリケーションは遅くなります。
一般的なルールとして、retainは常にallocatedより小さいか等しくなります。
MRIのヒープがサイズやメモリ断片につぶされないため、実際のRSSコストは常にわずかに高くなります。
Rbtrace
メモリフットプリントの増加の原因の一つは、Rubyのメモリ断片化かもしれません。
これを診断するには、Aaron Pattersonの投稿にあるように、Rubyのヒープを可視化することができます。
手始めに、調査対象のプロセスのヒープをJSONファイルにダンプします。
探検しているプロセスの内部でコマンドを実行する必要があります。rbtrace
. rbtrace
はGitLabGemfile
にすでに存在しているので、それを必要とするだけです。環境変数にENABLE_RBTRACE=1
を設定し、webserverやSidekiqを実行することで実現できます。
ヒープダンプを取得するには
bundle exec rbtrace -p <PID> -e 'File.open("heap.json", "wb") { |t| ObjectSpace.dump_all(output: t) }'
JSONを手に入れたら、Aaronが提供するスクリプトかそれに似たものを使って画像をレンダリングします:
ruby heapviz.rb heap.json
断片化されたRubyのヒープスナップショットは次のようになります:
メモリの断片化は、この投稿で説明されているようにGCパラメータを調整することで減らすことができます。これはメモリ割り当てとGCサイクルの全体的なパフォーマンスに影響する可能性があるため、トレードオフとして考慮する必要があります。
失敗したベンチマーク
derailed_benchmarks
は、「RailsやRubyアプリのベンチマークに使える一連のもの」と説明されているgemです。私 derailed_benchmarks
たちのGemfile
にderailed_benchmarks
含まれて derailed_benchmarks
います。
test
ステージがあるすべてのパイプラインで、memory-on-boot
というジョブでderailed exec perf:mem
を実行します(ジョブの例を読んでください…):
- マージリクエストの概要タブで、マージリクエストレポートエリアのメトリクスレポートドロップダウンリストにあります。
-
memory-on-boot
アーティファクトで、完全なレポートと依存関係の内訳を確認します。
derailed_benchmarks
には、メモリを調査するための他のメソッドも用意されています。詳しくはgemのドキュメントをご覧ください。ほとんどのメソッド(derailed exec perf:*
)では、Railsアプリをproduction
環境で起動し、それに対してベンチマークを実行しようとします。GDKでもGCKでも可能です:
- GDKの場合は、gemページの指示に従ってください。エラーを避けるため、Redisの設定についても同様のことを行う必要があります。
- GCKには、
production
の設定セクションが最初から含まれています。
変更のインポート
パフォーマンスの改善に取り組む際には、常に「このコードの一部のパフォーマンスを改善することがどれほど重要か」という問いを自問することが重要です。すべてのコードが同じように重要なわけではありませんし、ユーザーのごく一部にしか影響を与えないものを改善するために1週間も費やすのはもったいないことです。例えば、あるメソッドから10ミリ秒を絞り出すのに1週間を費やすのは時間の無駄です。
あるコードが最適化する価値があるかどうかを判断するための、明確な手順はありません。できることは2つだけです:
- そのコードが何をするのか、どのように使われるのか、何回呼び出されるのか、総実行時間(たとえば、ウェブリクエストに費やされる総時間)に対してどれだけの時間が費やされるのかを考えることです。
- 他の人に尋ねてください(できればイシューの形で)。
あまり重要でない/労力に値しない変更の例をいくつか:
- 二重引用符を一重引用符に置き換えること。
- 値のリストが非常に小さい場合、Array の使用法を Set に置き換えます。
- ライブラリAをライブラリBに置き換えるのは、どちらも実行時間全体の0.1%しか占めない場合。
- すべての文字列に対して
freeze
を呼び出します(文字列のフリーズを参照)。
スローオペレーションとSidekiq
ブランチのマージや、(外部APIを使用した)エラーが発生しやすいオペレーションなど、処理速度の遅いオペレーションは、可能な限りWebリクエストで直接実行するのではなく、Sidekiqワーカーで実行する必要があります。これには以下のような多くのメリットがあります:
- エラーによってリクエストが完了しないことはありません。
- プロセスが遅くてもページの読み込み時間には影響しません。
- 失敗した場合、プロセスを再試行することができます(Sidekiqは自動的にこれを処理します)。
- コードをWebリクエストから分離することで、テストとメンテナーが容易になるはずです。
Git オペレーションを扱う際には、Sidekiq をできるだけ使うことが特に重要です。これらのオペレーションは、基盤となるストレージシステムのパフォーマンスによっては完了までにかなりの時間がかかることがあるからです。
Gitオペレーション
不必要な Git オペレーションを実行しないように注意しましょう。たとえば、Repository#branch_names
を使ったブランチ名の一覧の取得は、リポジトリが存在するかどうかを明示的にチェックすることなく行うことができます。つまり、この代わりに
if repository.exists?
repository.branch_names.each do |name|
...
end
end
と書けばいいのです:
repository.branch_names.each do |name|
...
end
キャッシュ
同じ結果を返すことが多いオペレーション、特に Git のオペレーションは Redis を使ってキャッシュするようにしましょう。Redisでデータをキャッシュするときは、必要なときにキャッシュがフラッシュされるようにしましょう。たとえば、タグの一覧のキャッシュは、新しいタグがプッシュされたりタグが削除されたりするたびにフラッシュしなければなりません。
リポジトリにキャッシュ期限切れコードを追加する場合、このコードは Repository クラスに存在する before/after フックのいずれかに配置する必要があります。例えば、リポジトリをインポートした後にキャッシュをフラッシュする場合は、このコードをRepository#after_import
に追加します。 これにより、キャッシュロジックが他のクラスに漏れることなく、Repository クラス内に留まるようになります。
データをキャッシュするときは、インスタンス変数に結果をメモしておくようにしてください。Redis からのデータ取得は生の Git オペレーションよりもずっと高速ですが、それでもオーバーヘッドはあります。結果をインスタンス変数にキャッシュすることで、同じメソッドを繰り返しコールしても Redis からデータを取得する必要がなくなります。キャッシュされたデータをインスタンス変数にメモする場合は、キャッシュをフラッシュするときにインスタンス変数もリセットするようにしましょう。例を示します:
def first_branch
@first_branch ||= cache.fetch(:first_branch) { branches.first }
end
def expire_first_branch_cache
cache.expire(:first_branch)
@first_branch = nil
end
文字列の凍結
Rubyの最近のバージョンでは、文字列に対して.freeze
。例えば、Ruby 2.3以降では “foo “という文字列が一度だけ確保されます:
10.times do
'foo'.freeze
end
文字列のサイズと(.freeze
が追加される前の)割り当て頻度によっては、高速化さ_れるかも_しれませんが、保証されるわけではありません。
文字列を凍結するとメモリが節約されますが、これは割り当てられた文字列が少なくともRVALUE_SIZE
バイト(x64 では 40 バイト)のメモリを使用するからです。
メモリプロファイラを使って、どの文字列が頻繁に割り当てられていて、.freeze
。
Ruby 3.0では文字列はデフォルトでフリーズします。この事態に備えるため、すべてのRubyファイルに以下のヘッダを追加します:
# frozen_string_literal: true
このため、文字列を操作できることを期待するコードでテストが失敗する可能性があります。dup
を使う代わりに、unary plus を使って凍結されていない文字列を取得してください:
test = +"hello"
test += " world"
新しい Ruby ファイルを追加する際には、上記のヘッダーを追加できるかどうか確認してください。
Banzaiパイプラインとフィルタ
Banzaiのフィルターやパイプラインを書いたり更新したりするとき、フィルターの性能や、それがパイプライン全体の性能にどのような影響を与えるかを理解するのは難しいかもしれません。
ベンチマークを実行するには
bin/rake benchmark:banzai
このコマンドは次のような出力を生成します:
--> Benchmarking Full, Wiki, and Plain pipelines
Calculating -------------------------------------
Full pipeline 1.000 i/100ms
Wiki pipeline 1.000 i/100ms
Plain pipeline 1.000 i/100ms
-------------------------------------------------
Full pipeline 3.357 (±29.8%) i/s - 31.000
Wiki pipeline 2.893 (±34.6%) i/s - 25.000 in 10.677014s
Plain pipeline 15.447 (±32.4%) i/s - 119.000
Comparison:
Plain pipeline: 15.4 i/s
Full pipeline: 3.4 i/s - 4.60x slower
Wiki pipeline: 2.9 i/s - 5.34x slower
.
--> Benchmarking FullPipeline filters
Calculating -------------------------------------
Markdown 24.000 i/100ms
Plantuml 8.000 i/100ms
SpacedLink 22.000 i/100ms
...
TaskList 49.000 i/100ms
InlineDiff 9.000 i/100ms
SetDirection 369.000 i/100ms
-------------------------------------------------
Markdown 237.796 (±16.4%) i/s - 2.304k
Plantuml 80.415 (±36.1%) i/s - 520.000
SpacedLink 168.188 (±10.1%) i/s - 1.672k
...
TaskList 101.145 (± 6.9%) i/s - 1.029k
InlineDiff 52.925 (±15.1%) i/s - 522.000
SetDirection 3.728k (±17.2%) i/s - 34.317k in 10.617882s
Comparison:
Suggestion: 739616.9 i/s
Kroki: 306449.0 i/s - 2.41x slower
InlineGrafanaMetrics: 156535.6 i/s - 4.72x slower
SetDirection: 3728.3 i/s - 198.38x slower
...
UserReference: 2.1 i/s - 360365.80x slower
ExternalLink: 1.6 i/s - 470400.67x slower
ProjectReference: 0.7 i/s - 1128756.09x slower
.
--> Benchmarking PlainMarkdownPipeline filters
Calculating -------------------------------------
Markdown 19.000 i/100ms
-------------------------------------------------
Markdown 241.476 (±15.3%) i/s - 2.356k
これにより、様々なフィルタのパフォーマンスや、どのフィルタのパフォーマンスが最も遅いかを知ることができます。
テストデータはフィルターの性能に大きく関係します。テストデータの中に、特にフィルタを起動させるようなものがなければ、フィルタの動作は信じられないほど速く見えるかもしれません。spec/fixtures/markdown.md.erb
ファイルにフィルタに関連するテストデータがあることを確認してください。
特定のフィルタのベンチマーク
特定のフィルタをベンチマークするには、フィルタ名を環境変数に指定します。たとえば、MarkdownFilter
をベンチマークするには、次のようにします。
FILTER=MarkdownFilter bin/rake benchmark:banzai
を使います。
--> Benchmarking MarkdownFilter for FullPipeline
Warming up --------------------------------------
Markdown 271.000 i/100ms
Calculating -------------------------------------
Markdown 2.584k (±16.5%) i/s - 23.848k in 10.042503s
ファイルやその他のデータソースからの読み込み
Rubyには、ファイルの内容やI/Oストリーム全般を扱う便利な関数がいくつかあります。IO.read
やIO.readlines
などの関数を使うと、データをメモリに読み込むのは簡単ですが、データが大きくなると効率が悪くなります。これらの関数はデータ・ソースの内容全体をメモリに読み込むため、メモリ使用量は_少なくとも_データ・ソースのサイズ分だけ増加します。readlines
の場合、Ruby VM が各行を表現するために余計なブックキーピングを行うため、メモリ使用量はさらに増加します。
ディスク上に 750 MB のテキスト・ファイルを読み込む次のプログラムを考えてみましょう:
File.readlines('large_file.txt').each do |line|
puts line
end
以下は、プログラム実行中のプロセス・メモリの読み出しで、ファイル全体を実際にメモリに保持していたことを示しています(RSSはキロバイト単位で報告):
$ ps -o rss -p <pid>
RSS
783436
そしてこれがガベージ・コレクタが行っていたことの抜粋です:
pp GC.stat
{
:heap_live_slots=>2346848,
:malloc_increase_bytes=>30895288,
...
}
heap_live_slots
(到達可能なオブジェクトの数)が~2.3Mに跳ね上がったことがわかります。これは、ファイルを一行ずつ読み込むのと比べると、およそ2桁多い量です。増加したのは生のメモリ使用量だけでなく、ガベージコレクタ(GC) が将来のメモリ使用を見越して、この変化にどのように対応したかも重要でした。malloc_increase_bytes
、「新鮮な」Rubyプログラムではわずか~4 kBであるのに対し、~30 MBに跳ね上がったことがわかります。この図は、Ruby GCが次にメモリ不足になったときにオペレーションシステムから要求される追加のヒープ領域を示しています。私たちはより多くのメモリを占有しただけでなく、アプリケーションの動作を変更し、より速い速度でメモリ使用量を増加させました。
IO.read
関数も同様の挙動を示しますが、各行オブジェクトに対して余分なメモリが割り当てられないという違いがあります。
推奨
データソースを完全にメモリに読み込む代わりに、行ごとに読み込むほうがよいです。たとえば、YAML ファイルを RubyHash
に変換する必要がある場合などです。しかし、それぞれの行が何らかの実体を表し、処理された後に破棄されるデータがある場合、つぎのアプローチを使うことができます。
まず、readlines.each
の呼び出しをどちらかに置き換えますeach
each_line
。 each_line
と each
関数は、すでに訪れた行をメモリに保持することなく、データソースを一行ずつ読み取ります:
File.new('file').each { |line| puts line }
または、IO.readline
またはIO.gets
関数を使用して、明示的に内部行を読み取ることもできます:
while line = file.readline
# process line
end
ループを早めに抜けるような条件がある場合は、この方法が望ましいでしょう。メモリだけでなく、興味のない行を処理するためにCPUやI/Oに費やす不要な時間も節約できます。
アンチパターン
これは、これらの変更が本番環境に、測定可能で、重要で、ポジティブな影響を与えない限り、避けるべきアンチパターンのコレクションです。
アロケーションの定数への移動
オブジェクトを定数として保存し、割り当てを一度だけにするとパフォーマンスが向上する_可能性が_ありますが、これは保証されているわけではありません。定数の検索は実行時のパフォーマンスに影響を与えるため、オブジェクトを直接参照する代わりに定数を使用すると、コードが遅くなる可能性があります。例えば
SOME_CONSTANT = 'foo'.freeze
9000.times do
SOME_CONSTANT
end
このようにする唯一の理由は、誰かがグローバル文字列を変更するのを防ぐためです。しかし、Rubyでは定数を再割り当てするだけなので、コードの他の場所で誰かがこれを行うのを止めることはできません:
SOME_CONSTANT = 'bar'
数百万行のデータベースをシードする方法
たとえば、相対的なクエリのパフォーマンスを比較したり、バグを再現したりするために、ローカルデータベースに数百万行のプロジェクトが必要な場合があります。これをSQLコマンドで手作業で行うことも、Railsモデルの大量挿入機能を使うこともできます。
ActiveRecordモデルを使っている場合は、以下のリンクも参考になるでしょう:
使用例
このスニペットには便利な例がいくつかあります。