パフォーマンス・ガイドライン

この文書では、GitLabの良好で一貫したパフォーマンスを確保するために従うべき様々なガイドラインについて説明します。

ワークフロー

パフォーマンス問題を解決するプロセスは、おおよそ次のようなものです:

  1. どこかにイシューがオープンされていることを確認し(例えば GitLab CE issue tracker)、もしなければ作成してください。 例は#15607を参照してください。
  2. GitLab.comのような本番環境でコードのパフォーマンスを測定します(下記のツールのセクションを参照)。 パフォーマンスは、_少なくとも_24時間にわたって測定する必要があります。
  3. 測定期間に基づく調査結果(グラフのスクリーンショット、タイミングなど)をステップ1のイシューに追加してください。
  4. 問題を解決してください。
  5. マージリクエストを作成し、「パフォーマンス」ラベルを割り当て、パフォーマンスレビュープロセスに従います。
  6. 変更がデプロイされたら、少なくとも24時間測定し、変更が本番環境に影響を与えるかどうかを_再度_確認してください。
  7. これを終わるまで繰り返します。

時間帯を提供する場合は、必ず提供してください:

  • 95パーセンタイル
  • 99パーセンタイル
  • 平均

グラフのスクリーンショットを提供する場合は、X軸とY軸の両方と凡例がはっきりと見えるようにしてください。 GitLab.com独自の監視ツールにアクセスできる場合は、関連するグラフやダッシュボードへのリンクも提供してください。

ツール

GitLabはパフォーマンスと可用性を向上させるための組み込みツールを提供しています:

GitLabチームのメンバーは、https://dashboards.gitlab.netにあるGitLab.comのパフォーマンスモニタリングシステムを利用することができます。この場合、@gitlab.com のメールアドレスを使ってログインする必要があります。GitLabチームメンバー以外の方は、PrometheusとGrafanaスタックを自分でセットアップすることをお勧めします。

ベンチマーク

ベンチマークはほとんどの場合役に立ちません。 ベンチマークは通常、コードの小さな部分を単独でテストするだけで、多くの場合、最良のシナリオしか測定しません。 その上、(Gemのような)ライブラリのベンチマークは、ライブラリに有利に偏る傾向があります。 結局のところ、作成者が競合他社よりもパフォーマンスが悪いことを示すベンチマークを公開するメリットはほとんどありません。

ベンチマークが本当に役に立つのは、変更の影響を大まかに(”大まかに “を強調して)理解する必要があるときだけです。 たとえば、あるメソッドが遅い場合、ベンチマークを使用して、あなたが行っている変更がそのメソッドのパフォーマンスに何らかの影響を与えるかどうかを確認できます。 しかし、ベンチマークがあなたの変更によってパフォーマンスが向上することを示したとしても、本番環境でもパフォーマンスが向上するという保証はありません。

ベンチマークを書くときは、ほとんどの場合benchmark-ipsを使うべきです。標準ライブラリに付属している Ruby のBenchmarkモジュールは、1 回の繰り返し (Benchmark.bmを使った場合) か、2 回の繰り返し (Benchmark.bmbmを使った場合) しか実行しないので、ほとんど役に立ちません。このように少ない回数の繰り返ししか実行しないということは、バックグラウンドでストリーミングしているビデオなどの外部要因によって、ベンチマークの統計が非常に簡単に歪められてしまうことを意味します。

Benchmark モジュールのもうひとつの問題点は、反復ではなくタイミ ングを表示することです。 つまり、あるコードが非常に短時間で完了する場合、ある変更前と変更後のタイ ミングを比較するのが非常に難しくなります。 その結果、次のようなパターンが生じます:

Benchmark.bmbm(10) do |bench|
  bench.report 'do something' do
    100.times do
      ... work here ...
    end
  end
end

しかし、これは「意味のある統計を取るには何回繰り返せばいいのか?

benchmark-ips Gemは、基本的にこのようなことをすべて行ってくれますので、Benchmark

要するに:

  • インターネットで見つけたベンチマークを信用してはいけません。
  • ベンチマークだけに基づいて主張するのではなく、必ず本番で測定して結果を確認してください。
  • XがYよりN倍速いということは、それが実際に本番環境にどのような影響を与えるのかがわからなければ意味がありません。
  • 本番環境は、(パフォーマンス・モニタリング・システムが正しく設定されていない場合を除き)常に真実を伝える_唯一の_ベンチマークです。
  • どうしてもベンチマークを書きたい場合は、RubyのBenchmark モジュールの代わりに benchmark-ips Gem を使ってください。

プロファイリング

一定間隔でプロセスの状態のスナップショットを収集することで、プロファイリングはプロセスのどこに時間が費やされているかを見ることができます。StackProfgemはGitLabの開発環境に含まれており、疑わしいコードの動作を詳細に調査することができます。

アプリケーションのプロファイリングは、そのパフォーマンスを変化させ、一般的に代表的でない環境で行われることに注意することが重要です。 特に、あるメソッドが何度も実行されたり、実行に時間がかかったりしたからといって、必ずしも問題があるとは限りません。 プロファイルは、アプリケーションで何が起きているかをよりよく理解するために使用できるツールであり、その情報を賢く使用するかどうかはあなた次第です!

このことを念頭に置いて、プロファイルを作成するには、問題のあるコードパスを実行するスペックを特定(または作成)し、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 が通常取る引数を渡すことで、実行するスペックを制限することができます。

出力はデフォルトで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.dump --callgrind > project_policy_spec.callgrind
kcachegrind project_policy_spec.callgrind # Linux
qcachegrind project_policy_spec.callgrind # Mac

例えば、特定のメソッドにズームインするのに便利かもしれません:

$ stackprof tmp/project_policy_spec.rb.dump --method warm_asset_cache

TestEnv#warm_asset_cache (/Users/lupine/dev/gitlab.com/gitlab-org/gitlab-development-kit/gitlab/spec/support/test_env.rb:164)
  samples:     0 self (0.0%)  /   6288 total (36.9%)
  callers:
    6288  (  100.0%)  block (2 levels) in <top (required)>
  callees (6288 total):
    6288  (  100.0%)  Capybara::RackTest::Driver#visit
  code:
                                  |   164  |   def warm_asset_cache
                                  |   165  |     return if warm_asset_cache?
                                  |   166  |     return unless defined?(Capybara)
                                  |   167  |
 6288   (36.9%)                   |   168  |     Capybara.current_session.driver.visit '/'
                                  |   169  |   end
$ stackprof tmp/project_policy_spec.rb.dump --method BasePolicy#abilities
BasePolicy#abilities (/Users/lupine/dev/gitlab.com/gitlab-org/gitlab-development-kit/gitlab/app/policies/base_policy.rb:79)
  samples:     0 self (0.0%)  /     50 total (0.3%)
  callers:
      25  (   50.0%)  BasePolicy.abilities
      25  (   50.0%)  BasePolicy#collect_rules
  callees (50 total):
      25  (   50.0%)  ProjectPolicy#rules
      25  (   50.0%)  BasePolicy#collect_rules
  code:
                                  |    79  |   def abilities
                                  |    80  |     return RuleSet.empty if @user && @user.blocked?
                                  |    81  |     return anonymous_abilities if @user.nil?
   50    (0.3%)                   |    82  |     collect_rules { rules }
                                  |    83  |   end

プロファイルにはアプリケーションコードだけでなく、テストスイートが行う作業も含まれるため、これらのプロファイルは遅いテストの調査にも使用できます。 しかし、(この例のような)小規模な実行の場合、テストスイートのセットアップにかかるコストが支配的になりがちです。

また、特定のコードパスがトリガーされたときにプロファイルを出力するように、テストスイートを経由せずにアプリケーションコードをインプレースで変更することも可能です。 詳細はStackProf のドキュメントを参照してください。

RSpecプロファイリング

GitLab の開発環境にはrspec_profilinggem も含まれており、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 ジョブを実行する際にも保存されます。 これらのプロファイリングデータの統計は、オンラインで見ることができます。 たとえば、どのテストの実行に一番時間がかかるか、どのクエリが一番多く実行されるかを調べることができます。 これは、テストを最適化したり、コードのパフォーマンス上の問題を特定したりするのに便利です。

メモリプロファイリング

メモリフットプリント増加の原因のひとつは、Rubyメモリの断片化かもしれません。

これを診断するには、Aaron Pattersonの投稿にあるように、Rubyのヒープを可視化することができます。

まず始めに、調査対象のプロセスのヒープをJSONファイルにダンプします。

探検しているプロセスの内部でコマンドを実行する必要があります。rbtrace.rbtraceはGitLabGemfileにすでに存在しているので、それを必要とするだけです。環境変数にENABLE_RBTRACE=1を設定して、Webサーバーや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のヒープ・スナップショットは次のようになります:

Ruby heap fragmentation

メモリの断片化は、Nate Berkopec氏によるこの投稿で説明されているように、GCパラメータを調整することで減らすことができます。 これは、メモリ割り当てとGCサイクルの全体的なパフォーマンスに影響を与える可能性があるため、トレードオフとして考慮する必要があります。

変化の重要性

パフォーマンスの改善に取り組むときは、常に「このコードのパフォーマンスを改善することがどれだけ重要か」を自問自答することが重要です。 すべてのコードが同じように重要なわけではありませんし、ごく一部のユーザーにしか影響を与えないものを改善するために1週間も費やすのはもったいないことです。 たとえば、あるメソッドから10ミリ秒を絞り出すために1週間を費やしても、別の場所で10秒を絞り出すために1週間を費やせたかもしれないのに、時間の無駄です。

あるコードに最適化する価値があるかどうかを判断するための、明確な手順はありません。 できることは2つだけです:

  1. コードが何をするのか、どのように使われるのか、何回呼び出されるのか、総実行時間(たとえば、ウェブリクエストに費やされる総時間)に対してどれだけの時間が費やされるのかを考えてください。
  2. 他の人に聞いてみましょう(できればイシューの形で)。

あまり重要でない/努力に値しない変更の例をいくつか:

  • 二重引用符を一重引用符に置き換えます。
  • 値のリストが非常に小さい場合に、Arrayの使用をSetに置き換えました。
  • ライブラリAをライブラリBに置き換えても、実行時間全体の0.1%しか変わらない場合。
  • すべての文字列に対してfreeze を呼び出します(文字列の凍結を参照)。

スローオペレーション&Sidekiq

ブランチのマージなどの遅いオペレーションや、エラーになりやすいオペレーション(外部APIを使用)は、できるだけWebリクエストで直接行うのではなく、Sidekiqワーカーで行うべきです。 これには以下のような多くのメリットがあります:

  1. エラーはリクエストの完了を妨げません。
  2. プロセスが遅くても、ページの読み込み時間には影響しません。
  3. 失敗した場合、プロセスを再試行するのは簡単です(Sidekiqが自動的に処理します)。
  4. コードをウェブリクエストから分離することで、テストやメンテナーが容易になることが期待されます。

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

キャッシュ

同じ結果を返すことが多いオペレーションは、Redisを使ってキャッシュすべきです。 Redisにデータをキャッシュするときは、必要なときにキャッシュがフラッシュされるようにしましょう。 たとえば、タグのリストのキャッシュは、新しいタグがプッシュされたりタグが削除されたりするたびにフラッシュされるべきです。

リポジトリ用のキャッシュ期限切れコードを追加する場合、このコードはリポジトリクラスに存在するbefore/afterフックのいずれかに配置する必要があります。 例えば、リポジトリをインポートした後にキャッシュをフラッシュする必要がある場合、このコードはRepository#after_importに追加する必要があります。 これにより、キャッシュロジックが他のクラスに漏れることなく、リポジトリクラス内部に留まるようになります。

データをキャッシュする際には、その結果をインスタンス変数にメモしておくようにしましょう。 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 呼び出しが追加される前の)文字列の割り当て頻度によっては、高速化_できるかも_しれませんが、そうなる保証はありません。

Ruby3.0では文字列はデフォルトで凍結されます。この事態に備えて、すべてのRubyファイルに以下のヘッダを追加します:

# frozen_string_literal: true

これは、文字列を操作できることを期待するコードで、テストに失敗する原因となる可能性があります。dupを使う代わりに、単項プラスを使って、凍結されていない文字列を取得します:

test = +"hello"
test += " world"

Rubyファイルを新規に追加する場合、上記のヘッダを省略するとスタイルチェックに失敗することがありますので、追加できることを確認してください。

ファイルやその他のデータソースからの読み込み

Rubyには、ファイルの内容やI/Oストリーム全般を扱う便利な関数がいくつかあります。IO.readIO.readlines のような関数を使うと、データを簡単にメモリに読み込むことができますが、データが大きくなると効率が悪くなります。 これらの関数はデータソースの内容全体をメモリに読み込むため、メモリ使用量はデータソースのサイズ_分だけ_大きくなります。readlinesの場合は、Ruby VMが各行を表現するために余計なブックキーピングを行うため、さらに大きくなります。

ディスク上に750MBのテキストファイルを読み込む次のプログラムを考えてみましょう:

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 が~30MBに跳ね上がっているのがわかります。これは、”新しい “Rubyプログラムではわずか~4kBに過ぎません。この図は、RubyのGCが次にメモリ不足になったときに、オペレーティングシステムからどれだけの追加のヒープ領域を要求するかを示しています。 私たちはより多くのメモリを占有しただけでなく、より速い速度でメモリ使用量を増加させるようにアプリケーションの動作を変更しました。

IO.read 関数も同様の挙動を示しますが、各行オブジェクトに対して余分なメモリが割り当てられないという違いがあります。

おすすめ

データソースを完全にメモリに読み込む代わりに、一行ずつ読み込むほうがよいです。これは、たとえばYAMLファイルをRubyHashに変換する必要があるときなど、常に選択できるわけではありませんが、各行が何らかの実体を表し、それを処理して捨てることができるデータがあるときはいつでも、次のアプローチを使うことができます。

まず、readlines.each の呼び出しをどちらかに置き換えますeach each_lineeach_lineおよび each関数は、すでに訪問した行をメモリに保持することなく、データ・ソースを1行ずつ読み取ります:

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モデルを使っているのであれば、以下のリンクも参考になるでしょう:

使用例

このスニペットには役に立つ例がいくつかあります:https://gitlab.com/gitlab-org/gitlab-foss/snippets/33946