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

このドキュメントでは、GitLabの良好で一貫したパフォーマンスを確保するために従うべき様々なガイドラインについて説明します。パフォーマンス関連のページに移動するには、以下の索引セクションを参照してください。

パフォーマンスに関する文書

ワークフロー

パフォーマンス問題を解決するプロセスは、おおよそ以下の通りです:

  1. どこかにイシューが開かれていることを確認し(例えば GitLab CE のイシュー・トラッカー)、なければ作成します。例として#15607をご覧ください。
  2. GitLab.com のような本番環境で、コードのパフォーマンスを測定しましょう (後述のツールのセクションを参照ください)。パフォーマンスは、_少なくとも_24 時間にわたって測定してください。
  3. ステップ 1 で述べたイシューに、測定期間から得られた知見 (グラフのスクリーンショット、タイミングなど) を追加してください。
  4. 問題を解決してください。
  5. マージリクエストを作成し、「パフォーマンス」ラベルを割り当て、パフォーマンスレビュープロセスに従ってください。
  6. 変更がデプロイされたら、少なくとも 24 時間にわたって_再度_測定を行い、変更が本番環境に何らかの影響を与えるかどうかを確認してください。
  7. 完了するまで繰り返します。

タイムを伝えるときは、必ず

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

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

ツール

GitLabはパフォーマンスと可用性を向上させるビルトインツールを提供しています:

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 も含まれており、benchmarkbenchmark-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
note
Pumaマスタープロセスはサポートされていません。SIGUSR2を送ると再起動がトリガーされます。Pumaの場合は、Pumaワーカーにのみシグナルを送るように注意してください。

これは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_objects100M mem_bytes を超える割り当てを行わないことをお勧めします。GitLab.comで現在の使用量を見ることができます。

自作コードのメモリ圧迫のチェック

自分のコードを測定する方法は2つあります:

  1. メモリ割り当てカウンタを含むapi_json.log,development_json.log,sidekiq.log をレビューしてください。
  2. 与えられたコードブロックに対してGitlab::Memory::Instrumentation.with_memory_allocations を使用し、それをログに記録してください。
  3. 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_objects1000 の周りを作成します。基礎となる文字列オブジェクトは変わりませんが、この文字列への1000個の参照を割り当てる必要があります:

     Gitlab::Memory::Instrumentation.with_memory_allocations do
       1_000.times { '0123456789' }
     end
       
     => {:mem_objects=>1001, :mem_bytes=>0, :mem_mallocs=>0}
    
  • 文字列は動的に生成されるため、次の例ではmem_objects1000 の周りを作成します。文字列は動的に作成されるため、以下の例では , の文字列が作成されます:

     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_objects1000 の周囲に作成されます。文字列は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のヒープスナップショットは次のようになります:

Ruby heap fragmentation

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

失敗したベンチマーク

derailed_benchmarks は、「RailsやRubyアプリのベンチマークに使える一連のもの」と説明されているgemです。私 derailed_benchmarksたちのGemfilederailed_benchmarks 含まれて derailed_benchmarksいます。

test ステージがあるすべてのパイプラインで、memory-on-bootというジョブでderailed exec perf:mem を実行します(ジョブの例を読んでください…):

derailed_benchmarks には、メモリを調査するための他のメソッドも用意されています。詳しくはgemのドキュメントをご覧ください。ほとんどのメソッド(derailed exec perf:*)では、Railsアプリをproduction 環境で起動し、それに対してベンチマークを実行しようとします。GDKでもGCKでも可能です:

  • GDKの場合は、gemページの指示に従ってください。エラーを避けるため、Redisの設定についても同様のことを行う必要があります。
  • GCKには、production の設定セクションが最初から含まれています。

変更のインポート

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

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

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

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

  • 二重引用符を一重引用符に置き換えること。
  • 値のリストが非常に小さい場合、Array の使用法を Set に置き換えます。
  • ライブラリAをライブラリBに置き換えるのは、どちらも実行時間全体の0.1%しか占めない場合。
  • すべての文字列に対してfreeze を呼び出します(文字列のフリーズを参照)。

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

ブランチのマージや、(外部APIを使用した)エラーが発生しやすいオペレーションなど、処理速度の遅いオペレーションは、可能な限りWebリクエストで直接実行するのではなく、Sidekiqワーカーで実行する必要があります。これには以下のような多くのメリットがあります:

  1. エラーによってリクエストが完了しないことはありません。
  2. プロセスが遅くてもページの読み込み時間には影響しません。
  3. 失敗した場合、プロセスを再試行することができます(Sidekiqは自動的にこれを処理します)。
  4. コードを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.readIO.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_lineeach_lineeach関数は、すでに訪れた行をメモリに保持することなく、データソースを一行ずつ読み取ります:

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

使用例

このスニペットには便利な例がいくつかあります。