InfoGrab DocsInfoGrab Docs

성능 가이드라인

요약

이 문서는 GitLab의 일관되고 우수한 성능을 보장하기 위한 다양한 가이드라인을 설명합니다. 성능 문제를 해결하는 과정은 대략 다음과 같습니다. 타이밍을 제공할 때는 다음을 반드시 포함합니다. 그래프 스크린샷을 제공할 때는 X축과 Y축, 범례가 모두 명확히 보이는지 확인합니다.

이 문서는 GitLab의 일관되고 우수한 성능을 보장하기 위한 다양한 가이드라인을 설명합니다.

성능 문서#

워크플로#

성능 문제를 해결하는 과정은 대략 다음과 같습니다.

  1. 어딘가(예: GitLab CE 이슈 트래커)에 이슈가 열려 있는지 확인하고, 없으면 새로 생성합니다. 예시는 #15607을 참고합니다.
  2. GitLab.com과 같은 프로덕션 환경에서 코드의 성능을 측정합니다 (아래 도구 섹션을 참고합니다). 성능은 최소 24시간에 걸쳐 측정해야 합니다.
  3. 측정 기간을 바탕으로 얻은 결과(그래프 스크린샷, 타이밍 등)를 1단계에서 언급한 이슈에 추가합니다.
  4. 문제를 해결합니다.
  5. 머지 리퀘스트를 생성하고 "Performance" 레이블을 지정한 뒤 성능 리뷰 프로세스를 따릅니다.
  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 모듈은 단일 반복 (Benchmark.bm 사용 시) 또는 두 번의 반복(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 모듈 대신 이 gem을 사용해야 합니다.

GitLab Gemfile에는 benchmark-memory gem도 포함되어 있으며, 이는 benchmark 및 benchmark-ips gem과 유사하게 동작합니다. 다만 benchmark-memory는 그 대신 벤치마크 중에 할당되고 유지된 메모리 크기, 객체, 문자열을 반환합니다.

Gemfile에는 benchmark-swap gem도 포함되어 있습니다. 이 gem은 이미 실행 중인 메서드의 두 번째 구현을 벤치마킹합니다. 메서드를 먼저 두 개의 독립된 람다로 추출할 필요가 없으며, 이는 호출 체인 깊숙이 있는 메서드에서는 번거로운 작업입니다.

기존 메서드는 그대로 두고, 이름에 _perf 접미사를 붙인 두 번째 구현을 옆에 추가합니다. 그런 다음 코드를 실행하는 블록으로 Benchmark.swap을 호출합니다. 이 gem은 TracePoint로 블록을 추적해 짝을 찾고, 두 구현이 서로 다른 결과를 반환하면 경고합니다. 그런 다음 benchmark-ips로 각각을 벤치마킹해 비교합니다. 스왑은 그 자리에서 일어나므로 블록이 공개 진입점을 그대로 호출할 수 있습니다. 이렇게 측정하는 것은 메서드 하나가 아니라 전체 호출 체인입니다.

benchmark-swap은 require: false로 설정된 development 및 test 그룹에 속하므로 먼저 require "benchmark/swap"을 실행해야 합니다. Rails 콘솔에서 동작하며, 커밋하기 전에 _perf 메서드를 제거합니다.

Security::MergeReportsService#deduplicated_findings는 각 finding을 이미 확인한 식별자와 대조하기 위해 finding마다 Set을 생성합니다. Set#intersect?는 임의의 enumerable을 받으므로 finding별 Set은 필요하지 않습니다. 아래 예시는 실제 리포트 픽스처를 파싱한 뒤 공개 진입점인 execute를 벤치마킹하므로 병합 전체가 측정됩니다. class_eval은 콘솔 세션에서만 쓰이는 대체 구현을 정의하므로 파일이 변경되지 않고 커밋에도 반영되지 않습니다.

require "benchmark/swap"

Security::MergeReportsService.class_eval do
  def deduplicated_findings_perf
    prioritized_findings.each_with_object([[], Set.new]) do |finding, (deduplicated, seen_identifiers)|
      keys = finding.keys

      next if seen_identifiers.intersect?(keys)

      seen_identifiers.merge(keys)
      deduplicated << finding
    end.first
  end
end

project = Project.first
raise "no project in this instance, the parser needs one" unless project

pipeline = Ci::Pipeline.new(project: project)
fixture = Rails.root.join("spec/fixtures/security_reports/master/gl-sast-report.json")
json = File.read(fixture)

report = Gitlab::Ci::Reports::Security::Report.new(:sast, pipeline, Time.current)
Gitlab::Ci::Parsers::Security::Sast.parse!(json, report)

Benchmark.swap(time: 5, warmup: 2) do
  Security::MergeReportsService.new(report).execute.findings.map(&:uuid)
end

이 픽스처에서 execute는 약 1.4배 더 빠르게 실행됩니다.

benchmark-swap: swapping 1 method
  Security::MergeReportsService#deduplicated_findings -> deduplicated_findings_perf

Comparison:
original:    60314.2 i/s
 swapped:    84453.7 i/s - 1.40x  faster

요약하면 다음과 같습니다.

  • 인터넷에서 찾은 벤치마크를 믿지 않습니다.
  • 벤치마크만으로 결론을 내리지 말고, 항상 프로덕션에서 측정해 결과를 확인합니다.
  • X가 Y보다 N배 빠르다는 것은 프로덕션 환경에 미치는 영향을 모른다면 의미가 없습니다.
  • 프로덕션 환경은 언제나 진실을 말하는 유일한 벤치마크입니다 (성능 모니터링 시스템이 올바르게 설정되지 않은 경우는 예외입니다).
  • 벤치마크를 꼭 작성해야 한다면 Ruby의 Benchmark 모듈 대신 benchmark-ips Gem을 사용합니다.

Stackprof로 프로파일링#

일정한 간격으로 프로세스 상태의 스냅샷을 수집하는 프로파일링을 통해 프로세스에서 시간이 어디에 쓰이는지 확인할 수 있습니다. Stackprof gem은 GitLab에 포함되어 있어 CPU에서 실행 중인 코드를 상세히 프로파일링할 수 있습니다.

애플리케이션을 프로파일링하면 성능이 달라집니다. 프로파일링 전략마다 오버헤드가 다릅니다. Stackprof는 샘플링 프로파일러로, 설정 가능한 빈도(예: 100hz, 즉 초당 100개의 스택)로 실행 중인 스레드에서 스택 추적을 샘플링합니다. 이 유형의 프로파일링은 오버헤드가 0은 아니지만 상당히 낮으며 일반적으로 프로덕션에서도 안전한 것으로 간주됩니다.

프로파일러는 실제 상황을 대표하지 않는 환경에서 실행되더라도 개발 중에 매우 유용한 도구가 될 수 있습니다. 특히 어떤 메서드가 여러 번 실행되거나 실행 시간이 길다고 해서 반드시 문제가 있는 것은 아닙니다. 프로파일은 애플리케이션에서 무슨 일이 일어나는지 더 잘 이해하는 데 쓰는 도구이며, 그 정보를 현명하게 활용하는 것은 각자의 몫입니다.

Stackprof로 프로파일을 생성하는 방법은 여러 가지입니다.

코드 블록 감싸기#

특정 코드 블록을 프로파일링하려면 해당 블록을 Stackprof.run 호출로 감쌀 수 있습니다.

StackProf.run(mode: :wall, out: 'tmp/stackprof-profiling.dump') do
  #...
end

이렇게 하면 읽을 수 있는 .dump 파일이 생성됩니다. 사용 가능한 모든 옵션은 Stackprof 문서를 참고합니다.

Performance bar#

Performance bar를 사용하면 Stackprof로 요청을 프로파일링하고 결과를 즉시 Speedscope 플레임그래프로 출력할 수 있습니다.

Stackprof를 사용한 RSpec 프로파일링#

spec에서 프로파일을 생성하려면 문제가 되는 코드 경로를 실행하는 spec을 찾거나(또는 생성한 뒤), 다음처럼 bin/rspec-stackprof 헬퍼를 사용해 실행합니다.

$ bin/rspec-stackprof --limit=10 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이 일반적으로 받는 인수를 전달하면 실행할 spec을 제한할 수 있습니다.

프로덕션에서 Stackprof 사용#

Stackprof는 프로덕션 워크로드를 프로파일링하는 데도 사용할 수 있습니다.

Ruby 프로세스에 대한 프로덕션 프로파일링을 활성화하려면 STACKPROF_ENABLED 환경 변수를 true로 설정할 수 있습니다.

다음 구성 옵션을 설정할 수 있습니다.

  • STACKPROF_ENABLED: SIGUSR2 신호에서 Stackprof 신호 핸들러를 활성화합니다. 기본값은 false입니다.
  • STACKPROF_MODE: 샘플링 모드를 참고합니다. 기본값은 cpu입니다.
  • STACKPROF_INTERVAL: 샘플링 간격입니다. 단위 의미는 STACKPROF_MODE에 따라 다릅니다. object 모드에서는 이벤트별 간격(매 n번째 이벤트가 샘플링됨)이며 기본값은 100입니다. cpu와 같은 다른 모드에서는 빈도 간격이며 기본값은 10100 μs(99hz)입니다.
  • STACKPROF_FILE_PREFIX: 프로파일이 저장되는 파일 경로 접두사입니다. 기본값은 $TMPDIR(대개 /tmp에 해당)입니다.
  • STACKPROF_TIMEOUT_S: 프로파일링 타임아웃(초)입니다. 이 시간이 지나면 프로파일링이 자동으로 중지됩니다. 기본값은 30입니다.
  • STACKPROF_RAW: 원시 샘플을 수집할지 집계만 수집할지 여부입니다. 원시 샘플은 플레임 그래프를 생성하는 데 필요하지만 메모리 및 디스크 오버헤드가 더 높습니다. 기본값은 true입니다.

활성화되면 Ruby 프로세스에 SIGUSR2 신호를 보내 프로파일링을 트리거할 수 있습니다. 프로세스는 스택 샘플링을 시작합니다. 또 다른 SIGUSR2를 보내면 프로파일링이 중지됩니다. 또는 타임아웃이 지나면 자동으로 중지됩니다.

프로파일링이 중지되면 프로파일이 디스크의 $STACKPROF_FILE_PREFIX/stackprof.$PID.$RAND.profile에 기록됩니다. 그런 다음 Stackprof 프로파일 읽기 섹션에 설명된 대로 stackprof 명령줄 도구를 통해 추가로 검사할 수 있습니다.

현재 지원되는 프로파일링 대상은 다음과 같습니다.

  • Puma worker
  • Sidekiq
Note

Puma 마스터 프로세스는 지원되지 않습니다. SIGUSR2를 보내면 재시작이 트리거됩니다. Puma의 경우 Puma worker에만 신호를 보내도록 주의합니다.

이는 pkill -USR2 puma:로 수행할 수 있습니다. :는 puma 4.3.3.gitlab.2 ...(마스터 프로세스)와 puma: cluster worker 0: ...(worker 프로세스)를 구분하며, 후자를 선택합니다.

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

결과로 생성된 플레임 그래프를 만들고 볼 수도 있습니다. 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를 통해 플레임 그래프를 볼 수도 있습니다. performance bar를 사용할 때와 코드 블록을 프로파일링할 때 이를 확인할 수 있습니다. 이 옵션은 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로 spec을 프로파일링할 때 프로파일에는 테스트 스위트와 애플리케이션 코드가 수행한 작업이 함께 포함됩니다. 따라서 이 프로파일로 느린 테스트도 조사할 수 있습니다. 그러나 이 예시처럼 작은 실행에서는 테스트 스위트를 설정하는 데 드는 비용이 대부분을 차지하는 경향이 있습니다.

RSpec 프로파일링#

GitLab 개발 환경에는 rspec_profiling gem도 포함되어 있으며, 이 gem은 spec 실행 시간에 대한 데이터를 수집하는 데 쓰입니다. 이는 테스트 스위트 자체의 성능을 분석하거나 spec의 성능이 시간이 지나면서 어떻게 변했는지 확인하는 데 유용합니다.

로컬 환경에서 프로파일링을 활성화하려면 다음을 실행합니다.

export RSPEC_PROFILING=yes

이렇게 하면 rspec/profiling/에 CSV 파일이 생성되며, 테스트 실행마다 타임스탬프가 찍힌 파일이 만들어집니다. 각 파일에는 해당 세션의 모든 spec에 대한 타이밍, 쿼리 수, 쿼리 시간, 요청 수, 요청 시간, 기능 카테고리가 기록됩니다.

다른 출력 디렉터리를 사용하려면 RSPEC_PROFILING_FOLDER_PATH를 설정합니다.

전역 런타임 메트릭#

모든 테스트 실행 메트릭은 ClickHouse 데이터베이스 인스턴스로도 내보내져 Grafana 대시보드로 시각화됩니다.

Test Runtime Overview 대시보드는 가장 느린 spec 파일과 특정 spec 파일의 시간에 따른 런타임 추세를 보여줍니다.

메모리 최적화#

메모리 문제를 추적하는 데는 여러 기법을 흔히 함께 사용할 수 있습니다.

  • 코드를 그대로 두고 프로파일러로 감싸기.
  • 요청 및 서비스에 대한 메모리 할당 카운터 사용.
  • 문제가 있다고 의심되는 코드의 여러 부분을 비활성화/활성화하면서 프로세스의 메모리 사용량 모니터링.

메모리 할당#

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 사이클이 얼마나 자주 발생하는지에 영향을 미칩니다. 객체 할당이 적을수록 애플리케이션이 훨씬 더 빠르게 응답합니다.

웹 서버 요청은 100k mem_objects와 100M mem_bytes를 초과해 할당하지 않는 것이 좋습니다. 현재 사용량은 GitLab.com에서 확인할 수 있습니다.

자체 코드의 메모리 부담 확인#

자체 코드를 측정하는 방법에는 두 가지가 있습니다.

  1. 메모리 할당 카운터가 포함된 api_json.log, development_json.log, sidekiq.log를 검토합니다.
  2. 주어진 코드 블록에 대해 Gitlab::Memory::Instrumentation.with_memory_allocations를 사용하고 결과를 로깅합니다.
{"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에서 객체와 메모리가 할당되는 방식의 여러 측면을 나타냅니다.

  • 다음 예시는 문자열이 동결(freeze)될 수 있기 때문에 약 1000개의 mem_objects를 생성합니다. 기본 문자열 객체는 그대로 유지되지만, 이 문자열에 대한 참조 1000개는 여전히 할당해야 합니다.

    Gitlab::Memory::Instrumentation.with_memory_allocations do
      1_000.times { '0123456789' }
    end
    
    => {:mem_objects=>1001, :mem_bytes=>0, :mem_mallocs=>0}
    
  • 다음 예시는 문자열이 동적으로 생성되기 때문에 약 1000개의 mem_objects를 생성합니다. 각 문자열은 40바이트인 Ruby 슬롯에 맞기 때문에 추가 메모리를 할당하지 않습니다.

    Gitlab::Memory::Instrumentation.with_memory_allocations do
      s = '0'
      1_000.times { s * 23 }
    end
    
    => {:mem_objects=>1002, :mem_bytes=>0, :mem_mallocs=>0}
    
  • 다음 예시는 문자열이 동적으로 생성되기 때문에 약 1000개의 mem_objects를 생성합니다. 각 문자열이 40바이트인 Ruby 슬롯보다 크기 때문에 각각 추가 메모리를 할당합니다.

    Gitlab::Memory::Instrumentation.with_memory_allocations do
      s = '0'
      1_000.times { s * 24 }
    end
    
    => {:mem_objects=>1002, :mem_bytes=>32000, :mem_mallocs=>1000}
    
  • 다음 예시는 40kB가 넘는 데이터를 할당하지만 메모리 할당은 한 번만 수행합니다. 기존 객체는 이후 반복마다 재할당·크기 조정됩니다.

    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를 사용할 수 있습니다.

memory_profiler gem은 이미 GitLab Gemfile에 포함되어 있습니다. 현재 URL에 대해 performance bar 에서도 사용할 수 있습니다.

코드에서 memory profiler를 직접 사용하려면 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, 파일, 위치, 클래스별로 그룹화된 유지된 메모리와 할당된 메모리를 보여줍니다. memory profiler는 또한 문자열이 얼마나 자주 할당되고 유지되는지 보여주는 문자열 분석도 수행합니다.

유지된 메모리와 할당된 메모리#

  • 유지된 메모리(Retained memory): 코드 블록 실행으로 인해 유지되는 장기 메모리 사용량과 객체 수입니다. 메모리와 가비지 컬렉터에 직접적인 영향을 미칩니다.
  • 할당된 메모리(Allocated memory): 코드 블록 중에 발생한 모든 객체 할당과 메모리 할당입니다. 이는 메모리에는 영향이 미미할 수 있지만 성능에는 상당한 영향을 미칠 수 있습니다. 더 많은 객체를 할당할수록 더 많은 작업이 수행되며 애플리케이션이 더 느려집니다.

일반적으로 유지된 메모리는 항상 할당된 메모리보다 작거나 같습니다.

실제 RSS 비용은 MRI 힙이 크기에 맞게 압축되지 않고 메모리가 단편화되므로 항상 약간 더 높습니다.

Rbtrace#

메모리 사용량이 증가하는 원인 중 하나는 Ruby 메모리 단편화일 수 있습니다.

이를 진단하려면 Aaron Patterson의 이 글에서 설명한 대로 Ruby 힙을 시각화할 수 있습니다.

시작하려면 조사 중인 프로세스의 힙을 JSON 파일로 덤프해야 합니다.

조사 중인 프로세스 내부에서 명령을 실행해야 하며, rbtrace로 이를 수행할 수 있습니다. rbtrace는 이미 GitLab Gemfile에 포함되어 있으므로 require만 하면 됩니다. 환경 변수를 ENABLE_RBTRACE=1로 설정하고 웹 서버나 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 힙 단편화

메모리 단편화는 이 글에서 설명하는 대로 GC 매개변수를 조정하여 줄일 수 있습니다. 이는 메모리 할당과 GC 사이클의 전반적인 성능에 영향을 줄 수 있으므로 트레이드오프로 고려해야 합니다.

Derailed Benchmarks#

derailed_benchmarks는 "Rails 또는 Ruby 앱을 벤치마킹하는 데 사용할 수 있는 일련의 도구"로 설명되는 gem입니다. derailed_benchmarks는 Gemfile에 포함되어 있습니다.

test Stage가 있는 모든 파이프라인에서 memory-on-boot라는 job으로 derailed exec perf:mem을 실행합니다. (예시 job을 확인합니다..) 다음에서 결과를 확인할 수 있습니다.

  • 머지 리퀘스트의 Overview 탭, 머지 리퀘스트 보고서 영역의 Metrics Reports 드롭다운 목록.
  • 전체 보고서와 의존성 분류를 위한 memory-on-boot 아티팩트.

derailed_benchmarks는 메모리를 조사하는 다른 방법도 제공합니다. 자세한 내용은 gem 문서를 참고합니다. 대부분의 메서드(derailed exec perf:*)는 production 환경에서 Rails 앱을 부팅하여 이를 대상으로 벤치마크를 실행하려고 합니다. GDK와 GCK 모두에서 가능합니다.

  • GDK의 경우 gem 페이지의 지침을 따릅니다. 오류를 방지하려면 Redis 구성에 대해서도 유사하게 수행해야 합니다.
  • GCK에는 production 구성 섹션이 기본으로 포함되어 있습니다.

변경 사항의 중요성#

성능 개선 작업을 할 때는 항상 "이 코드 조각의 성능을 개선하는 것이 얼마나 중요한가?"라는 질문을 스스로에게 던지는 것이 중요합니다. 모든 코드가 동등하게 중요한 것은 아니며, 극히 일부 사용자에게만 영향을 미치는 것을 개선하는 데 일주일을 쓰는 것은 낭비입니다. 예를 들어 다른 곳에서 10초를 줄이는 데 일주일을 쓸 수 있었는데, 어떤 메서드에서 10밀리초를 줄이려고 일주일을 쓰는 것은 시간 낭비입니다.

특정 코드 조각이 최적화할 가치가 있는지 판단하는 데 따를 수 있는 명확한 단계는 없습니다. 할 수 있는 것은 다음 두 가지뿐입니다.

  1. 코드가 무엇을 하는지, 어떻게 쓰이는지, 얼마나 자주 호출되는지, 전체 실행 시간(예: 웹 요청에서 소요된 총 시간)에 비해 얼마나 많은 시간이 걸리는지 생각해 봅니다.
  2. 다른 사람에게 물어봅니다(가능하면 이슈 형태로).

실제로 중요하지 않거나 노력할 가치가 없는 변경 사항의 몇 가지 예는 다음과 같습니다.

  • 큰따옴표를 작은따옴표로 교체.
  • 값 목록이 매우 작을 때 Array를 Set으로 교체.
  • 둘 다 전체 실행 시간의 0.1%만 차지할 때 라이브러리 A를 라이브러리 B로 교체.
  • 모든 문자열에 freeze를 호출(문자열 동결 참고).

느린 작업과 Sidekiq#

브랜치 병합처럼 느린 작업이나 오류가 발생하기 쉬운 작업(외부 API 사용)은 가능한 한 웹 요청에서 직접 수행하는 대신 Sidekiq worker에서 수행해야 합니다. 이는 다음과 같은 수많은 이점이 있습니다.

  1. 오류가 발생해도 요청 완료가 막히지 않습니다.
  2. 프로세스가 느려도 페이지 로딩 시간에 영향을 미치지 않습니다.
  3. 실패한 경우 프로세스를 재시도할 수 있습니다(Sidekiq가 자동으로 처리합니다).
  4. 코드를 웹 요청에서 분리하면 테스트와 유지 관리가 더 쉬워집니다.

기본 스토리지 시스템의 성능에 따라 Git 작업이 완료되는 데 상당한 시간이 걸릴 수 있으므로, 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 버전에서는 String에 .freeze를 호출하면 한 번만 할당되고 재사용됩니다. 예를 들어 Ruby 2.3 이상에서는 다음이 "foo" String을 한 번만 할당합니다.

10.times do
  'foo'.freeze
end

String의 크기와 (.freeze 호출을 추가하기 전) 얼마나 자주 할당되었는지에 따라 이렇게 하면 더 빨라질 수 있지만, 이것이 보장되지는 않습니다.

문자열을 동결하면 메모리가 절약됩니다. 할당된 모든 문자열은 메모리에서 최소 하나의 RVALUE_SIZE 바이트(x64에서 40바이트)를 사용하기 때문입니다.

memory profiler를 사용하면 자주 할당되어 .freeze로 이득을 볼 수 있는 문자열을 확인할 수 있습니다.

문자열 할당을 줄이고 성능을 개선하려면 모든 Ruby 파일에 다음 헤더를 추가합니다. RuboCop은 코드베이스 전체의 일관성을 유지하기 위해 이 헤더를 강제합니다.

# frozen_string_literal: true

이로 인해 문자열을 조작할 수 있다고 가정하는 코드에서 테스트가 실패할 수 있습니다. dup 대신 단항 플러스를 사용해 동결되지 않은 문자열을 얻습니다.

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이 각 줄을 표현하기 위해 수행해야 하는 추가 부기(bookkeeping) 때문에 더 크게 늘어납니다.

디스크에서 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까지 늘어난 것을 볼 수 있으며, 이는 파일을 한 줄씩 읽었을 때보다 두 자릿수 정도 더 많은 값입니다. 단순히 원시 메모리 사용량만 늘어난 것이 아니라, 가비지 컬렉터(GC)가 향후 메모리 사용을 예상하여 이 변화에 반응하는 방식도 달라졌습니다. malloc_increase_bytes가 ~30MB까지 늘어났는데, 이는 "새로" 시작한 Ruby 프로그램의 ~4kB와 비교됩니다. 이 값은 다음에 메모리가 부족할 때 Ruby GC가 운영체제로부터 추가로 확보할 힙 공간을 나타냅니다. 더 많은 메모리를 점유한 것뿐 아니라, 애플리케이션이 더 빠른 속도로 메모리를 사용하도록 동작 방식도 바뀌었습니다.

IO.read 함수도 비슷하게 동작하지만, 각 줄 객체에 대한 추가 메모리가 할당되지 않는다는 차이가 있습니다.

권장 사항#

데이터 소스를 전체를 메모리로 읽는 대신 한 줄씩 읽는 것이 더 좋습니다. 예를 들어 YAML 파일을 Ruby Hash로 변환해야 할 때처럼 이것이 항상 가능한 선택은 아니지만, 각 행이 처리한 뒤 폐기할 수 있는 어떤 엔티티를 나타내는 데이터라면 다음 접근 방식을 사용할 수 있습니다.

먼저 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

이렇게 해야 하는 유일한 이유는 전역 String이 변경되는 것을 막기 위해서입니다. 그러나 Ruby에서는 상수를 그냥 재할당할 수 있으므로, 누군가 코드의 다른 곳에서 이렇게 하는 것을 막을 방법이 없습니다.

SOME_CONSTANT = 'bar'

데이터베이스에 수백만 개 행을 시딩하는 방법#

예를 들어 상대적인 쿼리 성능을 비교하거나 버그를 재현하기 위해 로컬 데이터베이스에 프로젝트 행을 수백만 개 두고 싶을 수 있습니다. SQL 명령을 직접 실행하거나 Mass Inserting Rails Models 기능을 사용해 이렇게 할 수 있습니다.

ActiveRecord 모델로 작업한다고 가정하면, 다음 링크도 도움이 될 수 있습니다.

예시#

유용한 예시는 이 스니펫에서 찾을 수 있습니다.

ExclusiveLease#

Gitlab::ExclusiveLease는 개발자가 분산 서버 간에 상호 배제를 달성할 수 있게 해주는 Redis 기반 잠금 메커니즘입니다. 개발자가 사용할 수 있는 몇 가지 래퍼가 있습니다.

  1. Gitlab::ExclusiveLeaseHelpers 모듈은 리스가 만료될 때까지 프로세스나 스레드를 차단하는 헬퍼 메서드를 제공합니다.
  2. ExclusiveLeaseGuard 모듈은 실행 중인 코드 블록에 대해 배타적 리스를 얻는 데 도움을 줍니다.

데이터베이스 트랜잭션 안에서는 ExclusiveLease를 사용하지 않아야 합니다. Redis I/O가 느려지면 유휴 트랜잭션 시간이 늘어날 수 있기 때문입니다. .try_obtain 메서드는 리스 시도가 데이터베이스 트랜잭션 내부에서 발생했는지 확인하고, Sentry와 log/exceptions_json.log에 예외를 기록합니다.

테스트나 개발 환경에서는 데이터베이스 트랜잭션 내의 리스 시도가 Gitlab::ExclusiveLease.skipping_transaction_check 블록 안에서 수행되지 않는 한 Gitlab::ExclusiveLease::LeaseWithinTransactionError를 발생시킵니다. 가능하면 스킵 기능은 spec에서만 사용하고, 이해하기 쉽도록 리스와 최대한 가깝게 배치해야 합니다. spec을 DRY하게 유지하기 위해, 트랜잭션 검사 스킵이 재사용되는 코드베이스 부분이 두 곳 있습니다.

  1. Users::Internal은 let_it_be에서 봇을 생성할 때 트랜잭션 검사를 건너뛰도록 패치되어 있습니다.
  2. :deploy_key에 대한 FactoryBot 팩토리는 DeployKey 모델을 생성하는 동안 트랜잭션을 건너뜁니다.

spec이나 픽스처가 아닌 파일에서 Gitlab::ExclusiveLease.skipping_transaction_check를 사용할 때는 이를 제거할 계획을 담은 infradev 이슈 링크를 포함해야 합니다.

성능 가이드라인

GitLab v19.4
원문 보기

요약

이 문서는 GitLab의 일관되고 우수한 성능을 보장하기 위한 다양한 가이드라인을 설명합니다. 성능 문제를 해결하는 과정은 대략 다음과 같습니다. 타이밍을 제공할 때는 다음을 반드시 포함합니다. 그래프 스크린샷을 제공할 때는 X축과 Y축, 범례가 모두 명확히 보이는지 확인합니다.

이 문서는 GitLab의 일관되고 우수한 성능을 보장하기 위한 다양한 가이드라인을 설명합니다.

성능 문서#

워크플로#

성능 문제를 해결하는 과정은 대략 다음과 같습니다.

  1. 어딘가(예: GitLab CE 이슈 트래커)에 이슈가 열려 있는지 확인하고, 없으면 새로 생성합니다. 예시는 #15607을 참고합니다.
  2. GitLab.com과 같은 프로덕션 환경에서 코드의 성능을 측정합니다 (아래 도구 섹션을 참고합니다). 성능은 최소 24시간에 걸쳐 측정해야 합니다.
  3. 측정 기간을 바탕으로 얻은 결과(그래프 스크린샷, 타이밍 등)를 1단계에서 언급한 이슈에 추가합니다.
  4. 문제를 해결합니다.
  5. 머지 리퀘스트를 생성하고 "Performance" 레이블을 지정한 뒤 성능 리뷰 프로세스를 따릅니다.
  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 모듈은 단일 반복 (Benchmark.bm 사용 시) 또는 두 번의 반복(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 모듈 대신 이 gem을 사용해야 합니다.

GitLab Gemfile에는 benchmark-memory gem도 포함되어 있으며, 이는 benchmark 및 benchmark-ips gem과 유사하게 동작합니다. 다만 benchmark-memory는 그 대신 벤치마크 중에 할당되고 유지된 메모리 크기, 객체, 문자열을 반환합니다.

Gemfile에는 benchmark-swap gem도 포함되어 있습니다. 이 gem은 이미 실행 중인 메서드의 두 번째 구현을 벤치마킹합니다. 메서드를 먼저 두 개의 독립된 람다로 추출할 필요가 없으며, 이는 호출 체인 깊숙이 있는 메서드에서는 번거로운 작업입니다.

기존 메서드는 그대로 두고, 이름에 _perf 접미사를 붙인 두 번째 구현을 옆에 추가합니다. 그런 다음 코드를 실행하는 블록으로 Benchmark.swap을 호출합니다. 이 gem은 TracePoint로 블록을 추적해 짝을 찾고, 두 구현이 서로 다른 결과를 반환하면 경고합니다. 그런 다음 benchmark-ips로 각각을 벤치마킹해 비교합니다. 스왑은 그 자리에서 일어나므로 블록이 공개 진입점을 그대로 호출할 수 있습니다. 이렇게 측정하는 것은 메서드 하나가 아니라 전체 호출 체인입니다.

benchmark-swap은 require: false로 설정된 development 및 test 그룹에 속하므로 먼저 require "benchmark/swap"을 실행해야 합니다. Rails 콘솔에서 동작하며, 커밋하기 전에 _perf 메서드를 제거합니다.

Security::MergeReportsService#deduplicated_findings는 각 finding을 이미 확인한 식별자와 대조하기 위해 finding마다 Set을 생성합니다. Set#intersect?는 임의의 enumerable을 받으므로 finding별 Set은 필요하지 않습니다. 아래 예시는 실제 리포트 픽스처를 파싱한 뒤 공개 진입점인 execute를 벤치마킹하므로 병합 전체가 측정됩니다. class_eval은 콘솔 세션에서만 쓰이는 대체 구현을 정의하므로 파일이 변경되지 않고 커밋에도 반영되지 않습니다.

require "benchmark/swap"

Security::MergeReportsService.class_eval do
  def deduplicated_findings_perf
    prioritized_findings.each_with_object([[], Set.new]) do |finding, (deduplicated, seen_identifiers)|
      keys = finding.keys

      next if seen_identifiers.intersect?(keys)

      seen_identifiers.merge(keys)
      deduplicated << finding
    end.first
  end
end

project = Project.first
raise "no project in this instance, the parser needs one" unless project

pipeline = Ci::Pipeline.new(project: project)
fixture = Rails.root.join("spec/fixtures/security_reports/master/gl-sast-report.json")
json = File.read(fixture)

report = Gitlab::Ci::Reports::Security::Report.new(:sast, pipeline, Time.current)
Gitlab::Ci::Parsers::Security::Sast.parse!(json, report)

Benchmark.swap(time: 5, warmup: 2) do
  Security::MergeReportsService.new(report).execute.findings.map(&:uuid)
end

이 픽스처에서 execute는 약 1.4배 더 빠르게 실행됩니다.

benchmark-swap: swapping 1 method
  Security::MergeReportsService#deduplicated_findings -> deduplicated_findings_perf

Comparison:
original:    60314.2 i/s
 swapped:    84453.7 i/s - 1.40x  faster

요약하면 다음과 같습니다.

  • 인터넷에서 찾은 벤치마크를 믿지 않습니다.
  • 벤치마크만으로 결론을 내리지 말고, 항상 프로덕션에서 측정해 결과를 확인합니다.
  • X가 Y보다 N배 빠르다는 것은 프로덕션 환경에 미치는 영향을 모른다면 의미가 없습니다.
  • 프로덕션 환경은 언제나 진실을 말하는 유일한 벤치마크입니다 (성능 모니터링 시스템이 올바르게 설정되지 않은 경우는 예외입니다).
  • 벤치마크를 꼭 작성해야 한다면 Ruby의 Benchmark 모듈 대신 benchmark-ips Gem을 사용합니다.

Stackprof로 프로파일링#

일정한 간격으로 프로세스 상태의 스냅샷을 수집하는 프로파일링을 통해 프로세스에서 시간이 어디에 쓰이는지 확인할 수 있습니다. Stackprof gem은 GitLab에 포함되어 있어 CPU에서 실행 중인 코드를 상세히 프로파일링할 수 있습니다.

애플리케이션을 프로파일링하면 성능이 달라집니다. 프로파일링 전략마다 오버헤드가 다릅니다. Stackprof는 샘플링 프로파일러로, 설정 가능한 빈도(예: 100hz, 즉 초당 100개의 스택)로 실행 중인 스레드에서 스택 추적을 샘플링합니다. 이 유형의 프로파일링은 오버헤드가 0은 아니지만 상당히 낮으며 일반적으로 프로덕션에서도 안전한 것으로 간주됩니다.

프로파일러는 실제 상황을 대표하지 않는 환경에서 실행되더라도 개발 중에 매우 유용한 도구가 될 수 있습니다. 특히 어떤 메서드가 여러 번 실행되거나 실행 시간이 길다고 해서 반드시 문제가 있는 것은 아닙니다. 프로파일은 애플리케이션에서 무슨 일이 일어나는지 더 잘 이해하는 데 쓰는 도구이며, 그 정보를 현명하게 활용하는 것은 각자의 몫입니다.

Stackprof로 프로파일을 생성하는 방법은 여러 가지입니다.

코드 블록 감싸기#

특정 코드 블록을 프로파일링하려면 해당 블록을 Stackprof.run 호출로 감쌀 수 있습니다.

StackProf.run(mode: :wall, out: 'tmp/stackprof-profiling.dump') do
  #...
end

이렇게 하면 읽을 수 있는 .dump 파일이 생성됩니다. 사용 가능한 모든 옵션은 Stackprof 문서를 참고합니다.

Performance bar#

Performance bar를 사용하면 Stackprof로 요청을 프로파일링하고 결과를 즉시 Speedscope 플레임그래프로 출력할 수 있습니다.

Stackprof를 사용한 RSpec 프로파일링#

spec에서 프로파일을 생성하려면 문제가 되는 코드 경로를 실행하는 spec을 찾거나(또는 생성한 뒤), 다음처럼 bin/rspec-stackprof 헬퍼를 사용해 실행합니다.

$ bin/rspec-stackprof --limit=10 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이 일반적으로 받는 인수를 전달하면 실행할 spec을 제한할 수 있습니다.

프로덕션에서 Stackprof 사용#

Stackprof는 프로덕션 워크로드를 프로파일링하는 데도 사용할 수 있습니다.

Ruby 프로세스에 대한 프로덕션 프로파일링을 활성화하려면 STACKPROF_ENABLED 환경 변수를 true로 설정할 수 있습니다.

다음 구성 옵션을 설정할 수 있습니다.

  • STACKPROF_ENABLED: SIGUSR2 신호에서 Stackprof 신호 핸들러를 활성화합니다. 기본값은 false입니다.
  • STACKPROF_MODE: 샘플링 모드를 참고합니다. 기본값은 cpu입니다.
  • STACKPROF_INTERVAL: 샘플링 간격입니다. 단위 의미는 STACKPROF_MODE에 따라 다릅니다. object 모드에서는 이벤트별 간격(매 n번째 이벤트가 샘플링됨)이며 기본값은 100입니다. cpu와 같은 다른 모드에서는 빈도 간격이며 기본값은 10100 μs(99hz)입니다.
  • STACKPROF_FILE_PREFIX: 프로파일이 저장되는 파일 경로 접두사입니다. 기본값은 $TMPDIR(대개 /tmp에 해당)입니다.
  • STACKPROF_TIMEOUT_S: 프로파일링 타임아웃(초)입니다. 이 시간이 지나면 프로파일링이 자동으로 중지됩니다. 기본값은 30입니다.
  • STACKPROF_RAW: 원시 샘플을 수집할지 집계만 수집할지 여부입니다. 원시 샘플은 플레임 그래프를 생성하는 데 필요하지만 메모리 및 디스크 오버헤드가 더 높습니다. 기본값은 true입니다.

활성화되면 Ruby 프로세스에 SIGUSR2 신호를 보내 프로파일링을 트리거할 수 있습니다. 프로세스는 스택 샘플링을 시작합니다. 또 다른 SIGUSR2를 보내면 프로파일링이 중지됩니다. 또는 타임아웃이 지나면 자동으로 중지됩니다.

프로파일링이 중지되면 프로파일이 디스크의 $STACKPROF_FILE_PREFIX/stackprof.$PID.$RAND.profile에 기록됩니다. 그런 다음 Stackprof 프로파일 읽기 섹션에 설명된 대로 stackprof 명령줄 도구를 통해 추가로 검사할 수 있습니다.

현재 지원되는 프로파일링 대상은 다음과 같습니다.

  • Puma worker
  • Sidekiq
Note

Puma 마스터 프로세스는 지원되지 않습니다. SIGUSR2를 보내면 재시작이 트리거됩니다. Puma의 경우 Puma worker에만 신호를 보내도록 주의합니다.

이는 pkill -USR2 puma:로 수행할 수 있습니다. :는 puma 4.3.3.gitlab.2 ...(마스터 프로세스)와 puma: cluster worker 0: ...(worker 프로세스)를 구분하며, 후자를 선택합니다.

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

결과로 생성된 플레임 그래프를 만들고 볼 수도 있습니다. 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를 통해 플레임 그래프를 볼 수도 있습니다. performance bar를 사용할 때와 코드 블록을 프로파일링할 때 이를 확인할 수 있습니다. 이 옵션은 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로 spec을 프로파일링할 때 프로파일에는 테스트 스위트와 애플리케이션 코드가 수행한 작업이 함께 포함됩니다. 따라서 이 프로파일로 느린 테스트도 조사할 수 있습니다. 그러나 이 예시처럼 작은 실행에서는 테스트 스위트를 설정하는 데 드는 비용이 대부분을 차지하는 경향이 있습니다.

RSpec 프로파일링#

GitLab 개발 환경에는 rspec_profiling gem도 포함되어 있으며, 이 gem은 spec 실행 시간에 대한 데이터를 수집하는 데 쓰입니다. 이는 테스트 스위트 자체의 성능을 분석하거나 spec의 성능이 시간이 지나면서 어떻게 변했는지 확인하는 데 유용합니다.

로컬 환경에서 프로파일링을 활성화하려면 다음을 실행합니다.

export RSPEC_PROFILING=yes

이렇게 하면 rspec/profiling/에 CSV 파일이 생성되며, 테스트 실행마다 타임스탬프가 찍힌 파일이 만들어집니다. 각 파일에는 해당 세션의 모든 spec에 대한 타이밍, 쿼리 수, 쿼리 시간, 요청 수, 요청 시간, 기능 카테고리가 기록됩니다.

다른 출력 디렉터리를 사용하려면 RSPEC_PROFILING_FOLDER_PATH를 설정합니다.

전역 런타임 메트릭#

모든 테스트 실행 메트릭은 ClickHouse 데이터베이스 인스턴스로도 내보내져 Grafana 대시보드로 시각화됩니다.

Test Runtime Overview 대시보드는 가장 느린 spec 파일과 특정 spec 파일의 시간에 따른 런타임 추세를 보여줍니다.

메모리 최적화#

메모리 문제를 추적하는 데는 여러 기법을 흔히 함께 사용할 수 있습니다.

  • 코드를 그대로 두고 프로파일러로 감싸기.
  • 요청 및 서비스에 대한 메모리 할당 카운터 사용.
  • 문제가 있다고 의심되는 코드의 여러 부분을 비활성화/활성화하면서 프로세스의 메모리 사용량 모니터링.

메모리 할당#

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 사이클이 얼마나 자주 발생하는지에 영향을 미칩니다. 객체 할당이 적을수록 애플리케이션이 훨씬 더 빠르게 응답합니다.

웹 서버 요청은 100k mem_objects와 100M mem_bytes를 초과해 할당하지 않는 것이 좋습니다. 현재 사용량은 GitLab.com에서 확인할 수 있습니다.

자체 코드의 메모리 부담 확인#

자체 코드를 측정하는 방법에는 두 가지가 있습니다.

  1. 메모리 할당 카운터가 포함된 api_json.log, development_json.log, sidekiq.log를 검토합니다.
  2. 주어진 코드 블록에 대해 Gitlab::Memory::Instrumentation.with_memory_allocations를 사용하고 결과를 로깅합니다.
{"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에서 객체와 메모리가 할당되는 방식의 여러 측면을 나타냅니다.

  • 다음 예시는 문자열이 동결(freeze)될 수 있기 때문에 약 1000개의 mem_objects를 생성합니다. 기본 문자열 객체는 그대로 유지되지만, 이 문자열에 대한 참조 1000개는 여전히 할당해야 합니다.

    Gitlab::Memory::Instrumentation.with_memory_allocations do
      1_000.times { '0123456789' }
    end
    
    => {:mem_objects=>1001, :mem_bytes=>0, :mem_mallocs=>0}
    
  • 다음 예시는 문자열이 동적으로 생성되기 때문에 약 1000개의 mem_objects를 생성합니다. 각 문자열은 40바이트인 Ruby 슬롯에 맞기 때문에 추가 메모리를 할당하지 않습니다.

    Gitlab::Memory::Instrumentation.with_memory_allocations do
      s = '0'
      1_000.times { s * 23 }
    end
    
    => {:mem_objects=>1002, :mem_bytes=>0, :mem_mallocs=>0}
    
  • 다음 예시는 문자열이 동적으로 생성되기 때문에 약 1000개의 mem_objects를 생성합니다. 각 문자열이 40바이트인 Ruby 슬롯보다 크기 때문에 각각 추가 메모리를 할당합니다.

    Gitlab::Memory::Instrumentation.with_memory_allocations do
      s = '0'
      1_000.times { s * 24 }
    end
    
    => {:mem_objects=>1002, :mem_bytes=>32000, :mem_mallocs=>1000}
    
  • 다음 예시는 40kB가 넘는 데이터를 할당하지만 메모리 할당은 한 번만 수행합니다. 기존 객체는 이후 반복마다 재할당·크기 조정됩니다.

    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를 사용할 수 있습니다.

memory_profiler gem은 이미 GitLab Gemfile에 포함되어 있습니다. 현재 URL에 대해 performance bar 에서도 사용할 수 있습니다.

코드에서 memory profiler를 직접 사용하려면 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, 파일, 위치, 클래스별로 그룹화된 유지된 메모리와 할당된 메모리를 보여줍니다. memory profiler는 또한 문자열이 얼마나 자주 할당되고 유지되는지 보여주는 문자열 분석도 수행합니다.

유지된 메모리와 할당된 메모리#

  • 유지된 메모리(Retained memory): 코드 블록 실행으로 인해 유지되는 장기 메모리 사용량과 객체 수입니다. 메모리와 가비지 컬렉터에 직접적인 영향을 미칩니다.
  • 할당된 메모리(Allocated memory): 코드 블록 중에 발생한 모든 객체 할당과 메모리 할당입니다. 이는 메모리에는 영향이 미미할 수 있지만 성능에는 상당한 영향을 미칠 수 있습니다. 더 많은 객체를 할당할수록 더 많은 작업이 수행되며 애플리케이션이 더 느려집니다.

일반적으로 유지된 메모리는 항상 할당된 메모리보다 작거나 같습니다.

실제 RSS 비용은 MRI 힙이 크기에 맞게 압축되지 않고 메모리가 단편화되므로 항상 약간 더 높습니다.

Rbtrace#

메모리 사용량이 증가하는 원인 중 하나는 Ruby 메모리 단편화일 수 있습니다.

이를 진단하려면 Aaron Patterson의 이 글에서 설명한 대로 Ruby 힙을 시각화할 수 있습니다.

시작하려면 조사 중인 프로세스의 힙을 JSON 파일로 덤프해야 합니다.

조사 중인 프로세스 내부에서 명령을 실행해야 하며, rbtrace로 이를 수행할 수 있습니다. rbtrace는 이미 GitLab Gemfile에 포함되어 있으므로 require만 하면 됩니다. 환경 변수를 ENABLE_RBTRACE=1로 설정하고 웹 서버나 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 힙 단편화

메모리 단편화는 이 글에서 설명하는 대로 GC 매개변수를 조정하여 줄일 수 있습니다. 이는 메모리 할당과 GC 사이클의 전반적인 성능에 영향을 줄 수 있으므로 트레이드오프로 고려해야 합니다.

Derailed Benchmarks#

derailed_benchmarks는 "Rails 또는 Ruby 앱을 벤치마킹하는 데 사용할 수 있는 일련의 도구"로 설명되는 gem입니다. derailed_benchmarks는 Gemfile에 포함되어 있습니다.

test Stage가 있는 모든 파이프라인에서 memory-on-boot라는 job으로 derailed exec perf:mem을 실행합니다. (예시 job을 확인합니다..) 다음에서 결과를 확인할 수 있습니다.

  • 머지 리퀘스트의 Overview 탭, 머지 리퀘스트 보고서 영역의 Metrics Reports 드롭다운 목록.
  • 전체 보고서와 의존성 분류를 위한 memory-on-boot 아티팩트.

derailed_benchmarks는 메모리를 조사하는 다른 방법도 제공합니다. 자세한 내용은 gem 문서를 참고합니다. 대부분의 메서드(derailed exec perf:*)는 production 환경에서 Rails 앱을 부팅하여 이를 대상으로 벤치마크를 실행하려고 합니다. GDK와 GCK 모두에서 가능합니다.

  • GDK의 경우 gem 페이지의 지침을 따릅니다. 오류를 방지하려면 Redis 구성에 대해서도 유사하게 수행해야 합니다.
  • GCK에는 production 구성 섹션이 기본으로 포함되어 있습니다.

변경 사항의 중요성#

성능 개선 작업을 할 때는 항상 "이 코드 조각의 성능을 개선하는 것이 얼마나 중요한가?"라는 질문을 스스로에게 던지는 것이 중요합니다. 모든 코드가 동등하게 중요한 것은 아니며, 극히 일부 사용자에게만 영향을 미치는 것을 개선하는 데 일주일을 쓰는 것은 낭비입니다. 예를 들어 다른 곳에서 10초를 줄이는 데 일주일을 쓸 수 있었는데, 어떤 메서드에서 10밀리초를 줄이려고 일주일을 쓰는 것은 시간 낭비입니다.

특정 코드 조각이 최적화할 가치가 있는지 판단하는 데 따를 수 있는 명확한 단계는 없습니다. 할 수 있는 것은 다음 두 가지뿐입니다.

  1. 코드가 무엇을 하는지, 어떻게 쓰이는지, 얼마나 자주 호출되는지, 전체 실행 시간(예: 웹 요청에서 소요된 총 시간)에 비해 얼마나 많은 시간이 걸리는지 생각해 봅니다.
  2. 다른 사람에게 물어봅니다(가능하면 이슈 형태로).

실제로 중요하지 않거나 노력할 가치가 없는 변경 사항의 몇 가지 예는 다음과 같습니다.

  • 큰따옴표를 작은따옴표로 교체.
  • 값 목록이 매우 작을 때 Array를 Set으로 교체.
  • 둘 다 전체 실행 시간의 0.1%만 차지할 때 라이브러리 A를 라이브러리 B로 교체.
  • 모든 문자열에 freeze를 호출(문자열 동결 참고).

느린 작업과 Sidekiq#

브랜치 병합처럼 느린 작업이나 오류가 발생하기 쉬운 작업(외부 API 사용)은 가능한 한 웹 요청에서 직접 수행하는 대신 Sidekiq worker에서 수행해야 합니다. 이는 다음과 같은 수많은 이점이 있습니다.

  1. 오류가 발생해도 요청 완료가 막히지 않습니다.
  2. 프로세스가 느려도 페이지 로딩 시간에 영향을 미치지 않습니다.
  3. 실패한 경우 프로세스를 재시도할 수 있습니다(Sidekiq가 자동으로 처리합니다).
  4. 코드를 웹 요청에서 분리하면 테스트와 유지 관리가 더 쉬워집니다.

기본 스토리지 시스템의 성능에 따라 Git 작업이 완료되는 데 상당한 시간이 걸릴 수 있으므로, 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 버전에서는 String에 .freeze를 호출하면 한 번만 할당되고 재사용됩니다. 예를 들어 Ruby 2.3 이상에서는 다음이 "foo" String을 한 번만 할당합니다.

10.times do
  'foo'.freeze
end

String의 크기와 (.freeze 호출을 추가하기 전) 얼마나 자주 할당되었는지에 따라 이렇게 하면 더 빨라질 수 있지만, 이것이 보장되지는 않습니다.

문자열을 동결하면 메모리가 절약됩니다. 할당된 모든 문자열은 메모리에서 최소 하나의 RVALUE_SIZE 바이트(x64에서 40바이트)를 사용하기 때문입니다.

memory profiler를 사용하면 자주 할당되어 .freeze로 이득을 볼 수 있는 문자열을 확인할 수 있습니다.

문자열 할당을 줄이고 성능을 개선하려면 모든 Ruby 파일에 다음 헤더를 추가합니다. RuboCop은 코드베이스 전체의 일관성을 유지하기 위해 이 헤더를 강제합니다.

# frozen_string_literal: true

이로 인해 문자열을 조작할 수 있다고 가정하는 코드에서 테스트가 실패할 수 있습니다. dup 대신 단항 플러스를 사용해 동결되지 않은 문자열을 얻습니다.

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이 각 줄을 표현하기 위해 수행해야 하는 추가 부기(bookkeeping) 때문에 더 크게 늘어납니다.

디스크에서 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까지 늘어난 것을 볼 수 있으며, 이는 파일을 한 줄씩 읽었을 때보다 두 자릿수 정도 더 많은 값입니다. 단순히 원시 메모리 사용량만 늘어난 것이 아니라, 가비지 컬렉터(GC)가 향후 메모리 사용을 예상하여 이 변화에 반응하는 방식도 달라졌습니다. malloc_increase_bytes가 ~30MB까지 늘어났는데, 이는 "새로" 시작한 Ruby 프로그램의 ~4kB와 비교됩니다. 이 값은 다음에 메모리가 부족할 때 Ruby GC가 운영체제로부터 추가로 확보할 힙 공간을 나타냅니다. 더 많은 메모리를 점유한 것뿐 아니라, 애플리케이션이 더 빠른 속도로 메모리를 사용하도록 동작 방식도 바뀌었습니다.

IO.read 함수도 비슷하게 동작하지만, 각 줄 객체에 대한 추가 메모리가 할당되지 않는다는 차이가 있습니다.

권장 사항#

데이터 소스를 전체를 메모리로 읽는 대신 한 줄씩 읽는 것이 더 좋습니다. 예를 들어 YAML 파일을 Ruby Hash로 변환해야 할 때처럼 이것이 항상 가능한 선택은 아니지만, 각 행이 처리한 뒤 폐기할 수 있는 어떤 엔티티를 나타내는 데이터라면 다음 접근 방식을 사용할 수 있습니다.

먼저 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

이렇게 해야 하는 유일한 이유는 전역 String이 변경되는 것을 막기 위해서입니다. 그러나 Ruby에서는 상수를 그냥 재할당할 수 있으므로, 누군가 코드의 다른 곳에서 이렇게 하는 것을 막을 방법이 없습니다.

SOME_CONSTANT = 'bar'

데이터베이스에 수백만 개 행을 시딩하는 방법#

예를 들어 상대적인 쿼리 성능을 비교하거나 버그를 재현하기 위해 로컬 데이터베이스에 프로젝트 행을 수백만 개 두고 싶을 수 있습니다. SQL 명령을 직접 실행하거나 Mass Inserting Rails Models 기능을 사용해 이렇게 할 수 있습니다.

ActiveRecord 모델로 작업한다고 가정하면, 다음 링크도 도움이 될 수 있습니다.

예시#

유용한 예시는 이 스니펫에서 찾을 수 있습니다.

ExclusiveLease#

Gitlab::ExclusiveLease는 개발자가 분산 서버 간에 상호 배제를 달성할 수 있게 해주는 Redis 기반 잠금 메커니즘입니다. 개발자가 사용할 수 있는 몇 가지 래퍼가 있습니다.

  1. Gitlab::ExclusiveLeaseHelpers 모듈은 리스가 만료될 때까지 프로세스나 스레드를 차단하는 헬퍼 메서드를 제공합니다.
  2. ExclusiveLeaseGuard 모듈은 실행 중인 코드 블록에 대해 배타적 리스를 얻는 데 도움을 줍니다.

데이터베이스 트랜잭션 안에서는 ExclusiveLease를 사용하지 않아야 합니다. Redis I/O가 느려지면 유휴 트랜잭션 시간이 늘어날 수 있기 때문입니다. .try_obtain 메서드는 리스 시도가 데이터베이스 트랜잭션 내부에서 발생했는지 확인하고, Sentry와 log/exceptions_json.log에 예외를 기록합니다.

테스트나 개발 환경에서는 데이터베이스 트랜잭션 내의 리스 시도가 Gitlab::ExclusiveLease.skipping_transaction_check 블록 안에서 수행되지 않는 한 Gitlab::ExclusiveLease::LeaseWithinTransactionError를 발생시킵니다. 가능하면 스킵 기능은 spec에서만 사용하고, 이해하기 쉽도록 리스와 최대한 가깝게 배치해야 합니다. spec을 DRY하게 유지하기 위해, 트랜잭션 검사 스킵이 재사용되는 코드베이스 부분이 두 곳 있습니다.

  1. Users::Internal은 let_it_be에서 봇을 생성할 때 트랜잭션 검사를 건너뛰도록 패치되어 있습니다.
  2. :deploy_key에 대한 FactoryBot 팩토리는 DeployKey 모델을 생성하는 동안 트랜잭션을 건너뜁니다.

spec이나 픽스처가 아닌 파일에서 Gitlab::ExclusiveLease.skipping_transaction_check를 사용할 때는 이를 제거할 계획을 담은 infradev 이슈 링크를 포함해야 합니다.