성능 지침

이 문서는 GitLab의 좋고 일관된 성능을 보장하기 위해 따를 수 있는 다양한 지침을 설명합니다. 성능 관련 페이지로 이동하려면 아래의 색인 섹션을 참조하세요.

성능 문서

Workflow

성능 문제 해결 프로세스는 대략 다음과 같습니다.

  1. 문제가 어딘가 열려 있는지 확인하고(예: GitLab CE 이슈 트래커에), 그렇지 않으면 생성하세요. 예: #15607를 참조하세요.
  2. 성능을 GitLab.com과 같은 프로덕션 환경에서 측정하세요(아래의 도구 섹션 참조). 성능은 최소 24시간 동안 측정되어야 합니다.
  3. 측정 기간 동안의 결과(그래프 스샷, 타이밍 등)을 1단계에서 언급된 이슈에 추가하세요.
  4. 문제를 해결하세요.
  5. 병합 요청을 생성하고 “성능” 레이블을 할당한 다음 성능 검토 프로세스를 따르세요.
  6. 변경이 배포되면 반드시 다시 최소 24시간 동안 측정하여 생산 환경에 어떤 영향을 미치는지 확인하세요.
  7. 완료될 때까지 반복하세요.

타이밍을 제공할 때 반드시 제공해야 하는 사항:

  • 95백분위수
  • 99백분위수
  • 평균

그래프 스샷을 제공할 때는 X 및 Y 축과 범례가 명확히 보이도록 하세요. GitLab.com의 모니터링 도구에 액세스할 수 있다면 관련된 그래프/대시보드에 대한 링크도 제공해야 합니다.

도구

GitLab은 성능과 가용성을 향상시키는 데 도움이 되는 기본 도구를 제공합니다.

GitLab 팀 멤버는 자신의 @gitlab.com 이메일 주소를 사용하여 dashboards.gitlab.net의 GitLab.com 성능 모니터링 시스템을 사용할 수 있습니다. GitLab 팀 멤버가 아닌 경우, 자체 Prometheus 및 Grafana 스택을 설정하는 것이 좋습니다.

벤치마크

벤치마크는 거의 항상 쓸모없습니다. 대부분 벤치마크는 일반적으로 고립된 코드의 작은 부분만 테스트하고 종종 최상의 시나리오만을 측정합니다. 게다가, 라이브러리에 대한 벤치마크는 종종 해당 라이브러리를 선호하도록 편향됩니다. 결국 어떤 작성자가 자신의 경쟁 상대보다 성능이 떨어지는 것을 보여주는 벤치마크를 게시하는 것에는 거의 이익이 없기 때문입니다.

벤치마크는 대개 귀하의 변경 사항의 영향을 대략적으로 이해해야 할 때에만 유용합니다. 예를 들어, 특정 메소드가 느리면 벤치마크를 사용하여 귀하의 변경 사항이 메소드의 성능에 어떤 영향을 미치는지 확인할 수 있습니다. 그런데, 벤치마크가 귀하의 변경 사항이 성능을 개선한다고 보여준다 하더라도 이러한 변경이 프로덕션 환경에서 성능도 개선될 것이라는 보장은 없습니다.

벤치마크를 작성할 때 거의 항상 표준 라이브러리와 함께 제공되는 Ruby의 Benchmark 모듈 대신 benchmark-ips를 사용해야 합니다. 또 다른 문제는 Benchmark 모듈이 시간을 표시하는 반면 반복 횟수를 표시하지 않는다는 것입니다. 이로 인해 일부 코드가 매우 짧은 시간에 완료되면 특정 변경 전후의 타이밍을 비교하는 것이 매우 어려울 수 있습니다. 이는 다음과 같은 패턴을 유발할 수 있습니다.

Benchmark.bmbm(10) do |bench|
  bench.report '작업' do
    100 반복
      ... 작업 ...
    end
  end
end

이러한 문제들은 전반적인 통계치를 얻기 위해 몇 번의 반복을 실행해야 하는지라는 질문을 던집니다.

benchmark-ips 젬은 이에 대해 많은 것을 다루고 있습니다. 따라서 Benchmark 모듈 대신 이를 사용해야 합니다.

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

요약하면:

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

Stackprof를 사용한 프로파일링

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

프로파일링은 응용 프로그램의 성능을 변화시킨다는 점이 중요합니다. 다양한 프로파일링 전략에는 각각 다른 오버헤드가 있습니다. Stackprof는 샘플링 프로파일러로써 실행 중인 스레드에서 스택 추적을 샘플링하여 구성 가능한 빈도(예: 100 hz, 즉 초당 100개의 스택)로 프로파일링합니다. 이러한 종류의 프로파일링에는 꽤 낮은(비록 0이 아닌) 오버헤드가 있으며 일반적으로 프로덕션 환경에서 안전하다고 간주됩니다.

프로파일러는 개발 중에 매우 유용한 도구일 수 있으며, 특정 환경에서 실행되지만도 대표적이지 않습니다. 특히, 특정 메서드가 많은 시간이 걸리거나 오랜 시간이 걸린다고 해서 반드시 문제가 있는 것은 아닙니다. 프로필은 응용 프로그램에서 무슨 일이 일어나고 있는지 더 잘 이해하기 위해 사용할 수 있는 도구입니다. 이 정보를 현명하게 활용하는 것은 여러분에 답합니다!

Stackprof로 프로파일을 생성하는 여러 가지 방법이 있습니다.

코드 블록 래핑

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

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

이렇게 하면 .dump 파일이 생성되며, 프로파일 읽기를 할 수 있습니다. 사용 가능한 옵션에 대해서는 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
      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입니다.

활성화한 후에는 Ruby 프로세스에 SIGUSR2 시그널을 보내어 프로파일링을 트리거할 수 있습니다. 프로세스는 스택을 샘플링하기 시작합니다. 프로파일링을 중지하려면 다시 SIGUSR2를 보낼 수 있습니다. 또는, 타임아웃 이후에 자동으로 중지됩니다.

프로파일링이 중지되면 프로필은 디스크에 $STACKPROF_FILE_PREFIX/stackprof.$PID.$RAND.profile 경로에 작성됩니다. 그런 다음 프로파일 읽기 섹션에서 설명한 대로 stackprof 명령행 도구를 통해 추가로 검사할 수 있습니다.

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

  • Puma 워커
  • Sidekiq

참고: Puma 마스터 프로세스는 지원되지 않습니다. Puma에 경우 SIGUSR2를 보내면 다시 시작이 트리거됩니다. Puma의 경우에는 pkill -USR2 puma:를 통해 신호를 보내기만 하면 됩니다. :puma 4.3.3.gitlab.2 ...(마스터 프로세스)와 puma: 클러스터 워커 0: ...(워커 프로세스)를 구분하기 위한 것입니다.

Sidekiq의 경우, pkill -USR2 bin/sidekiq-cluster를 통해 신호를 sidekiq-cluster 프로세스에 보내어 Sidekiq 자식에게 신호를 전달할 수 있습니다. 또는 특정 PID를 선택할 수도 있습니다.

스택프로파일(PROFILE) 읽기

기본적으로 출력은 ‘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를 실행할 때 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’s 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 프로파일링

깃랩 개발 환경에는 rspec_profiling 젬이 포함되어 있어 스펙 실행 시간에 대한 데이터를 수집하는 데 사용됩니다. 이는 테스트 스위트 자체의 성능을 분석하거나 특정 스펙의 성능이 시간이 지남에 따라 어떻게 변경되었는지 확인하는 데 유용합니다.

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

export RSPEC_PROFILING=yes
rake rspec_profiling:install

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

수집된 결과를 대화형 쉘에서 분석할 수도 있습니다:

$ 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 주기가 발생하는 빈도에 영향을 미칩니다. 더 적은 객체 할당은 응용 프로그램이 훨씬 더 반응적이라는 것을 의미합니다.

웹 서버 요청은 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_* 값은 Ruby에서 객체 및 메모리가 할당되는 다른 측면을 나타냅니다.

  • 다음 예제는 문자열이 동결될 수 있으므로 약 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가 생성됩니다. 각각은 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를 생성합니다. 각각은 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}
    
  • 다음 예제는 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)

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

유지된 메모리 vs 할당된 메모리

  • 유지된 메모리: 코드 블록 실행으로 유지된 장기 메모리 사용 및 객체 수입니다. 이것은 메모리와 가비지 수집기에 직접적으로 영향을 미칩니다.
  • 할당된 메모리: 코드 블록 동안의 모든 객체 할당 및 메모리 할당입니다. 이것은 메모리에는 미미한 영향을 미칠 수 있지만 성능에는 상당한 영향을 미칩니다. 할당한 객체가 많을수록 작업량이 많아지고 응용프로그램이 느려집니다.

일반적인 규칙으로는 유지된이 항상 할당된보다 작거나 같습니다.

MRI 힙은 크기가 조정되지 않으며 메모리 조각이기 때문에 실제 RSS 비용은 항상 약간 더 높습니다.

Rbtrace

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

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

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

rbtrace를 사용하여 조사 중인 프로세스 내에서 명령을 실행해야 합니다. rbtrace는 이미 GitLab Gemfile에 존재하며, 그냥 요구하면 됩니다. 웹 서버나 Sidekiq를 실행하고 환경 변수를 ENABLE_RBTRACE=1로 설정할 수 있습니다.

힙 덤프하는 방법은 다음과 같습니다:

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

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

모든 파이프라인에서 test 단계에 memory-on-boot이라는 작업을 실행하여 derailed exec perf:mem을 실행합니다. (예제 작업을 참조하십시오.) 결과는 다음에서 찾을 수 있습니다:

  • 병합 요청 개요 탭의 병합 요청 보고서 영역
  • Metrics Reports 드롭다운 목록memory-on-boot artifacts에서 전체 보고서 및 종속성 분석입니다.

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

  • GDK의 경우 젬 페이지에 있는 지침을 따르십시오. 오류를 피하려면 Redis 구성도 유사하게 수행해야 합니다.
  • GCK는 production 구성 섹션을 기본으로 포함하고 있습니다.

변경 사항의 중요성

성능 개선 작업 시에는 언제나 “이 코드의 성능을 개선하는 것이 얼마나 중요한가?”라는 질문을 하는 것이 중요합니다. 모든 코드 조각이 동등하게 중요한 것은 아니며, 작은 부분에 영향을 미치는 것을 개선하기 위해 일주일을 소비하는 것은 낭비일 수 있습니다. 예를 들어, 메소드에서 10밀리초를 빼내기 위해 일주일을 보내는 것은 10초를 다른 곳에서 뽑는 데 일주일을 보낼 수 있었던 것 처럼 시간 낭비입니다.

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

  1. 코드가 하는 일, 사용 방법, 호출 횟수 및 예를 들어 웹 요청의 총 실행 시간에 비해 얼마나 많은 시간이 소요되는지 등에 대해 고려해 보십시오.
  2. 다른 사람들에게 물어보십시오 (가능하면 이슈 형태로).

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

  • 이중 따옴표를 단일 따옴표로 대체하는 것.
  • 값 목록이 매우 작을 때 Array 사용을 Set으로 대체하는 것.
  • 라이브러리 A를 라이브러리 B로 대체하는 것, 둘 다 총 실행 시간의 0.1%만 차지할 때.
  • 모든 문자열에 freeze를 호출하는 것(참조: String Freezing).

느린 작업 및 Sidekiq

소스 브랜치를 병합하는 것과 같은 느린 작업 또는 (외부 API를 사용하는) 오류가 발생할 가능성이 있는 작업은 가능한 한 직접 웹 요청이 아닌 Sidekiq 워커에서 수행해야 합니다. 이에는 다음과 같은 많은 이점이 있습니다:

  1. 오류로 인해 요청이 완료되지 못하는 일을 방지합니다.
  2. 프로세스가 느리더라도 페이지의 로드 시간에 영향을 주지 않습니다.
  3. 실패한 경우 프로세스를 다시 시도할 수 있습니다(Sidekiq가 이를 자동으로 처리함).
  4. 웹 요청으로부터 코드를 격리함으로써 테스트 및 유지관리를 더 쉽게 할 수 있습니다.

특히 기존 저장 시스템의 성능에 따라 Git 작업에 상당한 시간이 소요될 수 있으므로 Git 작업과 관련된 경우 가능한 한 Sidekiq를 사용하는 것이 매우 중요합니다.

Git 작업

불필요한 Git 작업을 실행하지 않도록 주의해야 합니다. 예를 들어 Repository#branch_names를 사용하여 브랜치 이름 목록을 검색할 수 있는데 이때 명시적으로 저장소가 있는지 여부를 확인할 필요가 없습니다. 즉, 이렇게 쓰는 대신:

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 바이트(64비트 시스템에서 40바이트)의 메모리를 사용하는 모든 할당된 문자열의 메모리를 저장합니다.

또한, 이벤트에 대비하기 위해 Ruby 3.0에서는 기본적으로 문자열이 동결됩니다. 이를 대비하여 모든 Ruby 파일에 다음 헤더를 추가하고 있습니다:

# frozen_string_literal: true

이로 인해 문자열을 조작할 수 있는 코드에서 테스트 실패가 발생할 수 있습니다. 더 이상 dup 대신에 얼어붙지 않은 문자열을 얻기 위해 단항 플러스를 사용하세요:

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

새로운 Ruby 파일을 추가할 때 위의 헤더를 추가할 수 있는지 확인하고 이를 빠트리면 스타일 체크 실패로 이어질 수 있으니 주의하세요.

Banzai 파이프라인 및 필터

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

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

bin/rake benchmark:banzai

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

--> 전체, 위키 및 일반 파이프라인 벤치마킹
계산 중 -------------------------------------
       전체 파이프라인     1.000  i/100ms
       위키 파이프라인     1.000  i/100ms
      일반 파이프라인     1.000  i/100ms
-------------------------------------------------
       전체 파이프라인      3.357  (±29.8%) i/s -     31.000
       위키 파이프라인      2.893  (±34.6%) i/s -     25.000  in  10.677014s
      일반 파이프라인     15.447  (±32.4%) i/s -    119.000

비교:
      일반 파이프라인:       15.4 i/s
       전체 파이프라인:        3.4 i/s - 4.60배 느림
       위키 파이프라인:        2.9 i/s - 5.34배 느림

...
--> Plain 파이프라인 필터 벤치마킹
계산 중 -------------------------------------
            마크다운    24.000  i/100ms
            Plantuml     8.000  i/100ms
          SpacedLink    22.000  i/100ms

...

            작업 목록    49.000  i/100ms
          인라인 차이     9.000  i/100ms
            방향 설정   369.000  i/100ms
-------------------------------------------------
            마크다운    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

...

            작업 목록    101.145  (± 6.9%) i/s -      1.029k
          인라인 차이     52.925  (±15.1%) i/s -    522.000
            방향 설정      3.728k (±17.2%) i/s -     34.317k in  10.617882s

비교:
          제안:   739616.9 i/s
               Kroki:   306449.0 i/s - 2.41배 느림
InlineGrafanaMetrics:   156535.6 i/s - 4.72배 느림
        방향 설정:     3728.3 i/s - 198.38배 느림

...

       사용자 참조:        2.1 i/s - 360365.80배 느림
        외부 링크:        1.6 i/s - 470400.67배 느림
    프로젝트 참조:        0.7 i/s - 1128756.09배 느림

...
--> 일반 마크다운 파이프라인 필터 벤치마킹
계산 중 -------------------------------------
            마크다운    19.000  i/100ms
-------------------------------------------------
            마크다운    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의 경우, 각 줄을 표현하기 위해 Ruby VM이 수행해야 하는 추가 작업으로 인해 더욱 많이 증가합니다.

다음은 디스크에 있는 750MB 크기의 텍스트 파일을 읽는 아래 프로그램입니다.

File.readlines('large_file.txt').each do |line|
  puts line
end

다음은 프로그램 실행 중인 상태에서의 프로세스 메모리를 보여주는 내용입니다. 여기에는 우리가 실제로 전체 파일을 메모리에 유지했음을 보여줍니다 (킬로바이트 단위의 RSS로 표시):

$ ps -o rss -p <pid>

RSS
783436

또한 가비지 수집기가 하는 작업 일부를 보여주는 부분입니다.

pp GC.stat

{
 :heap_live_slots=>2346848,
 :malloc_increase_bytes=>30895288,
 ...
}

우리는 heap_live_slots (도달 가능한 객체 수)가 약 2.3M로 증가했음을 알 수 있고, 이는 한 줄씩 파일을 읽는 것에 비해 대략 두 단계의 크기입니다. 우리는 메모리 사용뿐만 아니라 앞으로의 메모리 사용에 대한 GC의 예측을 바꾸는 애플리케이션의 동작도 변경했습니다. malloc_increase_bytes가 약 30MB로 증가했음을 볼 수 있는데, 이는 “새로운” Ruby 프로그램의 경우에는 단지 약 4kB에 불과합니다. 이 수치는 Ruby GC가 메모리 부족 상태에서 다음에 운영 체제로부터 청구할 추가 힙 공간의 크기를 지정합니다. 우리는 메모리를 더 많이 사용했을 뿐만 아니라 애플리케이션의 동작도 변경하여 메모리 사용률이 더 빠르게 증가하도록 했습니다.

IO.read 함수도 각 줄 객체에 대해 추가 메모리가 할당되지 않는 유사한 동작을 보입니다.

권장사항

데이터 소스를 전체적으로 메모리에 읽는 대신 한 줄씩 읽는 것이 더 나은 방법입니다. 이것이 항상 선택사항은 아니며, 예를 들어 각 행이 처리된 후에 폐기될 수 있는 데이터가 있는 경우에는 다음 접근 방법을 사용할 수 있습니다.

먼저, readlines.each 호출을 each 또는 each_line으로 대체하세요. each_lineeach 함수는 이미 방문한 라인을 메모리에 보관하지 않고 데이터 소스를 한 줄씩 읽습니다.

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 명령이나 Rails 모델의 대량 삽입 기능을 사용하여 수동으로 수행할 수 있습니다.

ActiveRecord 모델로 작업하는 경우, 다음 링크가 도움이 될 수 있습니다:

예시

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

ExclusiveLease

Gitlab::ExclusiveLease는 분산된 서버 전체에서 상호 배타적인 접근을 가능하게 하는 Redis 기반의 잠금 메커니즘입니다. 개발자들이 사용할 수 있는 여러 래퍼(wrapper)가 있습니다:

  1. Gitlab::ExclusiveLeaseHelpers 모듈은 프로세스나 스레드가 임대 기한이 만료될 때까지 블록되는 도우미 메서드를 제공합니다.
  2. ExclusiveLease::Guard 모듈은 코드 블록에 대한 배타적 임대를 얻는 데 도움을 줍니다.

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

테스트나 개발 환경에서 데이터베이스 트랜잭션 내에서 임대 시도는 Gitlab::ExclusiveLease::LeaseWithinTransactionError를 일으킵니다. 이 오류는 Gitlab::ExclusiveLease.skipping_transaction_check 블록 내에서 수행되지 않을 경우입니다. 우리가 가능한 경우, 스펙(spec)에서만 스킵 기능을 사용하고 이해하기 쉽도록 가능한 가까운 위치에 배치해야 합니다. 스펙을 DRY(Don’t Repeat Yourself)하게 유지하기 위해 트랜잭션 확인 스킵은 코드베이스 내의 두 부분에서 재사용됩니다:

  1. Users::Internallet_it_be에서 봇 생성을 위한 트랜잭션 확인을 스킵하기 위해 패치되었습니다.
  2. :deploy_keyFactoryBot 팩토리는 DeployKey 모델 생성 중에 트랜잭션을 스킵합니다.

비스펙 또는 비픽스처 파일에서의 Gitlab::ExclusiveLease.skipping_transaction_check 사용은 계획된 제거 작업에 대한 인프라 개발(infradev) 이슈 링크를 포함해야 합니다.