성능 지침

이 문서는 GitLab의 좋고 일관된 성능을 보장하기 위한 다양한 지침을 설명합니다.

성능 문서

Workflow

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

  1. 반드시 문제가 어딘가에 열려 있는지 확인하세요(예: GitLab CE 이슈 추적기에서) 그리고 없으면 생성하세요. 예시로 #15607를 참조하세요.
  2. 성능은 적어도 24시간 동안 GitLab.com과 같은 프로덕션 환경에서 측정되어야 합니다(아래의 도구 섹션 참조).
  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 스택을 설정하는 것이 좋습니다.

벤치마킹

벤치마킹은 거의 항상 유용하지 않습니다. 벤치마킹은 보통 고립된 코드 조각들만 테스트하고 종종 최상의 경우만 측정합니다. 게다가 라이브러리의 벤치마킹은 보통 해당 라이브러리를 호의적으로 측정합니다. 결국 저자가 경쟁 상대보다 성능이 낮다는 것을 보여주는 벤치마킹에는 거의 이익이 없기 때문입니다.

벤치마킹은 대체로 귀하의 변경 사항이 어느 정도 영향을 미치는지 대략적인(강조는 “대략적인”) 이해가 필요한 경우에만 유용합니다. 예를 들어, 특정 메소드가 느리다면 벤치마크를 사용하여 귀하의 변경 사항이 메소드의 성능에 영향을 미치는지 확인할 수 있습니다. 그러나 심지어 벤치마크가 귀하의 변경 사항이 성능을 향상시킨다는 것을 보여준다 해도, 이는 프로덕션 환경에서 성능 또한 향상된다는 보장은 없습니다.

벤치마크를 작성할 때 거의 항상 benchmark-ips를 사용해야 합니다. 표준 라이브러리와 함께 제공되는 Ruby의 Benchmark 모듈은 거의 유용하지 않습니다. 왜냐하면 Benchmark.bm을 사용하면 단일 반복을 실행하고, Benchmark.bmbm을 사용하면 두 번의 반복을 실행하기 때문입니다. 이렇게 적은 반복을 실행하면 배경에서 비디오 스트리밍과 같은 외부 요인이 벤치마크 통계를 매우 쉽게 왜곡시킬 수 있기 때문입니다.

Benchmark 모듈의 다른 문제점은 시간을 표시하는 것이 아니라 반복을 표시합니다. 따라서 코드 조각이 매우 짧은 기간 내에 완료되면 특정 변경 전후의 시간을 비교하는 것이 매우 어려울 수 있습니다. 이로 인해 다음과 같은 패턴이 나타납니다:

Benchmark.bmbm(10) do |bench|
  bench.report '무언가를 수행' do
    100 반복하며
      ... 작업 내용 ...
    수행
  end
end

그러나 여기에서 질문이 생깁니다: 유의미한 통계를 얻기 위해 우리는 얼마나 많은 반복을 실행해야 할까요?

benchmark-ips 젬는 이 모든 것과 그 이상을 처리합니다. 따라서 Benchmark 모듈 대신에 사용해야 합니다.

GitLab Gemfile에는 비슷한 기능을 하는 benchmark-memory 젬도 포함되어 있습니다. 하지만, benchmark-memory는 벤치마크 전후에 메모리 크기, 할당되고 유지된 객체 및 문자열을 반환합니다.

요약하면:

  • 인터넷에서 찾는 벤치마크를 신뢰하지 마세요.
  • 벤치마크에 근거하여 주장하는 것은 절대로하지 마세요. 반드시 귀하의 결과를 확인하기 위해 프로덕션에서 측정하세요.
  • X가 Y보다 N배 빠르다는 것은 그것이 프로덕션 환경에 어떤 영향을 미치는지 모른다면 의미가 없습니다.
  • 프로덕션 환경은 항상 진실을 말하는 유일한 벤치마크입니다(성능 모니터링 시스템이 정확하게 설정되어 있지 않은 한).
  • 반드시 Benchmark 모듈 대신 benchmark-ips 젬을 사용하세요.

Stackprof로 프로파일링

정기적으로 프로세스 상태 스냅샷을 수집함으로써 프로파일링은 프로세스 내에서 시간이 어디에 소비되는지 확인할 수 있습니다. Stackprof 젬은 GitLab에 포함되어 있어 CPU에서 실행 중인 코드를 자세히 프로파일링할 수 있습니다.

프로파일링 방법에 따라 애플리케이션의 성능이 변경된다는 것이 중요합니다. 다양한 프로파일링 전략에는 서로 다른 오버헤드가 있습니다. Stackprof는 샘플링 프로파일러입니다. 구동중인 스레드에서 스택 추적을 지정된 빈도로 샘플링합니다(예: 100hz, 즉 초당 100 스택). 이 유형의 프로파일링은 상당한(비록 0은 아니지만) 오버헤드가 낮으며 일반적으로 프로덕션 환경에서 안전하다고 여겨집니다.

개발 중에도 프로파일러는 매우 유용한 도구일 수 있습니다. 특정하게, 특정 메서드가 많이 실행되거나 실행하는 데 오랜 시간이 걸릴 때 반드시 문제가 되지 않습니다. 프로파일은 애플리케이션에서 무슨 일이 일어나고 있는지 더 잘 이해할 수 있는 도구이며, 이 정보를 현명하게 사용하는 것은 여러분에게 달려 있습니다!

Stackprof로 프로필을 생성하는 여러 방법이 있습니다.

코드 블록 래핑

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

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

이렇게 하면 .dump 파일이 생성되며, Stackprof 프로필 읽는 방법을 확인할 수 있습니다. 모든 사용 가능한 옵션은 Stackprof 문서를 참조하십시오.

성능 막대

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

Stackprof를 사용한 RSpec 프로파일링

특정 코드 경로를 실행하는 스펙을 식별(또는 생성)하고, 예를 들어 다음과 같이 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: 샘플링 간격. 단위 의도에 따라 달라집니다. object 모드의 경우, 이는 이벤트당 간격입니다(모든 nth 이벤트가 샘플링됨). 기본값은 100입니다. cpu와 같은 다른 모드의 경우, 이는 빈도 간격이며 기본값은 10100 μs (99 hz)입니다.
  • 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 워커
  • Sidekiq

참고: Puma 마스터 프로세스는 지원되지 않습니다. 그에 대한 SIGUSR2를 전달하면 다시 시작됩니다. Puma의 경우 pkill -USR2 puma:로 신호를 보내는 경우가 있는데, puma 4.3.3.gitlab.2 ... (마스터 프로세스)와 puma: 클러스터 워커 0: ... (워커 프로세스)를 선택하는 차이가 있습니다.

Sidekiq의 경우, 신호를 sidekiq-cluster 프로세스로 보낼 수 있습니다. pkill -USR2 bin/sidekiq-cluster는 모든 Sidekiq 자식에 신호를 전달합니다. 또는 관심 대상의 특정 PID를 선택할 수도 있습니다.

Stackprof 프로필 읽기

기본적으로 샘플 열로 정렬된 출력물입니다. 이는 현재 실행 중인 메소드가 있는 샘플의 수입니다. 총합 열은 메소드(또는 해당 메소드를 호출하는 메소드)가 실행된 샘플의 수를 보여줍니다.

호출 스택의 그래픽 보기를 생성하려면:

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를 실행할 때 RAW 환경 변수를 true로 설정해야 합니다:

화염 그래프를 생성하는 데는 시간이 걸릴 수 있습니다.

# 생성
stackprof --flamegraph tmp/group_member_policy_spec.rb.dump > group_member_policy_spec.flame

# 보기
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

스택프로프를 사용하여 스펙을 프로파일링할 때, 프로필에는 테스트 스위트 및 응용 프로그램 코드에서 수행된 작업이 포함됩니다. 따라서 이러한 프로필을 사용하여 느린 테스트를 조사할 수 있습니다. 그러나 이 예와 같이 작은 실행에서는 테스트 스위트를 설정하는 비용이 주로 들어갑니다.

RSpec 프로파일링

GitLab 개발 환경에는 테스트 스펙 실행 시간에 대한 데이터를 수집하는 데 사용되는 rspec_profiling이 포함되어 있습니다. 이것은 테스트 스위트 자체의 성능을 분석하거나 특정 스펙의 성능이 어떻게 변화했는지를 확인하는 데 유용합니다.

로컬 환경에서 프로파일링을 활성화하려면 다음을 실행하세요:

export RSPEC_PROFILING=yes
rake rspec_profiling:install

이렇게 하면 RSPEC_PROFILING 환경 변수를 설정하여 스펙을 실행할 때마다 통계가 저장되는 SQLite3 데이터베이스가 tmp/rspec_profiling에 생성됩니다.

수집된 결과에 대한 즉석 조사는 대화식 셸에서 수행할 수 있습니다:

$ 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에 포함된 루비는 메모리 할당 추적을 허용하는 특별한 패치가 포함되어 있습니다.
이 패치는 기본으로 사용할 수 있으며 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_objects100M mem_bytes를 초과하여 할당하지 않는 것이 좋습니다. 현재 사용량은 GitLab.com에서 확인할 수 있습니다.

자체 코드의 메모리 압력 확인

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

  1. 메모리 할당 카운터가 포함된 api_json.log, development_json.log, sidekiq.log 검토.
  2. 지정된 코드 블록에 대해 Gitlab::Memory::Instrumentation.with_memory_allocations를 사용하고 로그 기록.
  3. 측정 모듈 사용
{"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_* 값은 루비에서 객체 및 메모리 할당의 다른 측면을 나타냅니다.

  • 다음 예제는 문자열이 동결될 수 있으므로 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을 만듭니다.
    각각은 루비 슬롯보다 큰 문자열이므로 추가 메모리를 할당합니다.

    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}
    
  • 다음 예제는 1천 개 이상의 객체를 생성하고 1천 번 이상의 할당을 수행하여 객체를 변형합니다.
    이 작업은 많은 데이터를 복사하고 많은 메모리 할당을 수행하므로 매우 비효율적입니다.
    이는 매우 비효율적인 문자열 추가 방법을 나타내는데 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 젬은 GitLab의 Gemfile에 이미 포함되어 있습니다. 또한 현재 URL의 성능 바 에서 사용할 수 있습니다.

코드에서 메모리 프로파일러를 직접 사용하려면 require를 사용하여 추가하세요.

require 'memory_profiler'

report = MemoryProfiler.report do
  # 프로파일링하려는 코드
end

output = File.open('/tmp/profile.txt','w')
report.pretty_print(output)

보고서에는 젬, 파일, 위치 및 클래스별로 그룹화된 보유 및 할당된 메모리가 표시됩니다.
메모리 프로파일러는 또한 문자열이 얼마나 자주 할당되고 보유되는지 보여주는 문자열 분석을 수행합니다.

보존된 메모리 대 할당된 메모리

  • 보존된 메모리: 코드 블록 실행으로 인해 유지되는 오랜 기간 사용 및 객체 수에 대한 메모리 유지. 이는 메모리 및 가비지 수집기에 직접적인 영향을 미칩니다.
  • 할당된 메모리: 코드 블록 중의 모든 객체 할당 및 메모리 할당. 이는 메모리에는 미미한 영향을 미칠 수 있지만 성능에는 상당한 영향을 미칠 수 있습니다. 할당하는 객체가 많을수록 작업이 더 많이 수행되고 애플리케이션이 느려질 수 있습니다.

일반적으로 보존된은 항상 할당된보다 작거나 같습니다.

실제 RSS 비용은 항상 MRI 힙이 크기로 조정되지 않고 메모리 조각으로 인해 약간 더 높을 수 있습니다.

Rbtrace

증가된 메모리 풋프린트의 이유 중 하나는 루비 메모리 단편화일 수 있습니다.

이를 진단하려면 Aaron Patterson의 이 게시물에 설명된 대로 루비 힙을 시각화할 수 있습니다.

먼저 조사 중인 프로세스의 힙을 JSON 파일로 덤프해야 합니다.

rbtrace를 사용하여 조사 중인 프로세스 내에서 명령을 실행해야 합니다. rbtrace는 이미 GitLab Gemfile에 포함되어 있으며, 그냥 요구하면 됩니다. 이는 웹서버나 환경 변수를 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 힙 단편화

메모리 단편화는 이 게시물에 설명된대로 GC 매개변수를 조정함으로써 줄일 수 있습니다. 이는 전반적인 메모리 할당 및 GC 주기의 성능에 영향을 미칠 수 있으므로 상충 관계로 고려해야 합니다.

Derailed Benchmarks

derailed_benchmarks는 “Rails 또는 Ruby 앱을 벤치마크하는 데 사용할 수 있는 일련의 도구”로 설명되는 gem입니다. 우리는 derailed_benchmarks를 우리의 Gemfile에 포함시킵니다.

우리는 모든 파이프라인에 대해 test 단계에서 memory-on-boot라는 작업에서 derailed exec perf:mem을 실행합니다. (예시 작업을 읽어보세요..) 결과는 다음에서 찾을 수 있습니다:

  • 병합 요청의 개요 탭에서 메트릭 보고서 드롭다운 목록 내의 메트릭 보고서 영역.
  • memory-on-boot 아티팩트에서 완전한 보고서 및 의존성 분석.

derailed_benchmarks는 메모리를 조사하기 위한 다른 메소드도 제공합니다. 자세한 정보는 gem 문서를 참조하세요. 대부분의 메소드(derailed exec perf:*)는 앱을 production 환경에서 부트스트랩하고 벤치마크를 실행하기 위해 시도합니다. 이는 GDK와 GCK에서 모두 가능합니다.

변경 사항의 중요성

성능 개선 작업을 수행할 때 항상 스스로에게 “이 코드 조각의 성능을 개선하는 것이 얼마나 중요한가?”라는 질문하는 것이 중요합니다. 모든 코드 조각이 동일하게 중요한 것은 아니며, 사용자의 작은 부분에만 영향을 미치는 것을 개선하려다가 한 주 동안을 낭비하는 것은 비효율적일 수 있습니다. 예를 들어, 어떤 메소드에서 10밀리초를 빼내려다가 한 주 동안을 10초를 뺄 수 있는 다른 곳에 사용하는 것은 시간 낭비입니다.

특정 코드 조각을 최적화할 가치가 있는지를 결정하기 위해 따를 수 있는 명확한 절차가 없습니다. 할 수 있는 것은 두 가지뿐입니다:

  1. 코드의 기능, 사용 방법, 호출 횟수 및 총 실행 시간에 비해 소요된 시간과 같은 것에 대해 생각해보는 것입니다(예를 들어, 웹 요청의 총 시간).
  2. 다른 이들에게 물어보는 것입니다(가능하면 이슈 형태로).

중요하지 않거나 투자할 가치가 없는 변경 사항의 몇 가지 예시:

  • 이중 인용부호를 단일 인용부호로 바꾸는 것.
  • 값 목록이 매우 작을 때 배열의 사용을 Set으로 바꾸는 것.
  • 각각이 총 실행 시간의 0.1%만 차지하는 A 라이브러리를 B 라이브러리로 바꾸는 것.
  • 모든 문자열에 freeze를 호출하는 것(자세한 내용은 문자열 고정을 참조).

느린 작업 및 Sidekiq

브랜치 병합과 같은 느린 작업이나 외부 API 사용과 같이 오류가 발생하기 쉬운 작업은 가능한 한 직접적인 웹 요청이 아니라 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

캐싱

자주 동일한 결과를 반환하는 작업은 특히 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 대신에 비냉동 문자열을 얻으려면 단항 플러스를 사용하세요.

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

새로운 Ruby 파일을 추가할 때 위의 헤더를 추가할 수 있는지 확인하고, 이를 누락하는 것은 스타일 체크 실패로 이어질 수 있습니다.

Banzai 파이프라인과 필터

Banzai 필터와 파이프라인을 작성하거나 업데이트할 때, 해당 필터의 성능과 전체 파이프라인 성능에 미치는 영향을 이해하기 어려울 수 있습니다.

벤치마크를 수행하려면 다음을 실행하세요.

bin/rake benchmark:banzai

이 명령은 다음과 같은 출력을 생성합니다.

--> Full, Wiki 및 Plain 파이프라인 벤치마킹
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

.
--> FullPipeline 필터 벤치마킹
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

.
--> PlainMarkdownPipeline 필터 벤치마킹
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

이를 통해 아래와 같은 출력이 생성됩니다.

--> FullPipeline에 대한 MarkdownFilter 벤치마킹
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의 경우 더욱 많은 추가 작업을 수행하여 가비지 컬렉터가 각 줄을 표시하는 데 필요한 메모리도 증가합니다.

다음은 디스크에 750MB인 텍스트 파일을 읽는 프로그램과 프로세스 메모리 독서의 일부분입니다.

$ 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는 다음에 메모리가 부족할 때 루비 GC가 운영 체제로부터 청구한 추가 힙 공간의 크기를 나타내는 것으로 약 30MB로 증가했습니다. 마지막으로 우리는 메모리 사용량이 증가했을 뿐만 아니라 애플리케이션의 동작 방식까지 변화시켰음을 알 수 있습니다.

IO.read 함수는 줄마다 추가 메모리를 할당한다는 차이를 제외하고는 유사한 동작을 합니다.

추천 사항

데이터 소스를 전체적으로 메모리에 읽어들이는 대신 줄 단위로 읽는 것이 좋습니다. 이것이 항상 옵션이 되는 것은 아닙니다. 예를 들어 YAML 파일을 Ruby Hash로 변환해야 하는 경우 등입니다. 그러나 각 행이 처리되고 나서 폐기될 수 있는 데이터가 있는 경우 다음 접근 방식을 사용할 수 있습니다.

먼저 readlines.each 호출을 each 또는 each_line으로 대체합니다. eacheach_line 함수는 이미 방문한 줄을 메모리에 보관하지 않고 데이터 소스를 줄 단위로 읽습니다.

File.new('file').each { |line| puts line }

또는 IO.readline 또는 IO.gets 함수를 사용하여 개별적인 줄을 명시적으로 읽을 수 있습니다.

while line = file.readline
   # 한줄 처리
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. ExclusiveLease::Guard 모듈은 실행 중인 코드 블록에 대한 배타적 임대를 가져오는 데 도움을 줍니다.

데이터베이스 트랜잭션에서 ExclusiveLease를 사용해서는 안 됩니다. 느린 Redis I/O가 유휴 트랜잭션 기간을 증가시킬 수 있습니다. .try_obtain 메서드는 임대 시도가 어떤 데이터베이스 트랜잭션 내에서 수행되는지 확인하고 Sentry 및 log/exceptions_json.log에 예외를 추적합니다.

테스트나 개발 환경에서는 데이터베이스 트랜잭션에서의 모든 임대 시도는 Gitlab::ExclusiveLease.skipping_transaction_check 블록 내에서 수행되지 않을 경우 Gitlab::ExclusiveLease::LeaseWithinTransactionError를 발생시킵니다. 가능한 경우 스펙에서만 스킵 기능을 사용하고 가능한 가까운 임대 쪽에 배치합니다. 스펙을 DRY하게 유지하기 위해 트랜잭션 확인 스킵이 다시 사용되는 코드베이스의 두 부분이 있습니다:

  1. Users::Internallet_it_be에서 봇 생성의 트랜잭션 확인을 건너뛰기 위해 패치됩니다.
  2. FactoryBot:deploy_key에 대한 트랜잭션 검사를 스킵하고 DeployKey 모델 생성이 이루어집니다.

비-스펙 또는 비-픽스처 파일에서 Gitlab::ExclusiveLease.skipping_transaction_check를 사용하는 것은 반드시 인프라 개발 이슈에 대한 링크를 포함해야 합니다.이것을 제거할 계획에 대한 계획이 있을 때에 대한 문제입니다.