성능 가이드라인

이 문서는 GitLab의 좋고 일관된 성능을 보장하기 위한 다양한 지침을 설명합니다. 색인 섹션을 참조하여 성능 관련 페이지로 이동하십시오.

성능 문서

워크플로우

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

  1. 어딘가에 이슈가 열려 있는지 확인합니다 (예: GitLab CE 이슈 트래커 등), 그리고 없을 경우 만듭니다. 예시로 #15607를 참조하십시오.
  2. 성능을 GitLab.com과 같은 프로덕션 환경에서 메트릭합니다 (아래의 도구 섹션 참조). 성능은 최소 24시간 동안 메트릭되어야 합니다.
  3. 메트릭 기간에 따라 찾은 내용을 (그래프 스크린샷, 타이밍 등) 1단계에서 언급된 이슈에 추가합니다.
  4. 문제를 해결합니다.
  5. Merge Request을 생성하고 “Performance” 레이블을 지정하고 성능 검토 프로세스를 따릅니다.
  6. 변경 사항이 배포되면 꼭 다시 최소 24시간 동안 메트릭하여 프로덕션 환경에 어떠한 영향을 미치는지 확인합니다.
  7. 완료될 때까지 반복합니다.

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

  • 95번째 백분위수
  • 99번째 백분위수
  • 평균값

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

도구

GitLab은 성능과 가용성을 개선하기 위한 내장 도구를 제공합니다:

GitLab 팀 구성원은 자신의 @gitlab.com 이메일 주소를 사용하여 dashboards.gitlab.net에 액세스할 수 있는 GitLab.com의 성능 모니터링 시스템을 사용할 수 있습니다. GitLab 팀 구성원이 아닌 경우 자체 프로메테우스와 그라파나 스택을 설정하는 것이 좋습니다.

벤치마크

벤치마크는 거의 항상 쓸모가 없습니다. 벤치마크는 보통 코드의 작은 부분만 테스트하고 종종 최상황만을 메트릭합니다. 게다가, 라이브러리에 대한 벤치마크는 종종 해당 라이브러리를 선호하는 편향된 경향이 있습니다. 결국, 경쟁 상대보다 성능이 더 나쁘게 나오는 벤치마크를 발표하는 것은 작성자에게는 별다른 이익이 없습니다.

벤치마크는 대부분 당신의 변경 사항의 영향을 대략적으로 (강조가 “대략적”임을 강조합니다) 이해해야 하는 경우에만 유용합니다. 예를 들어, 특정 메소드가 느릴 경우 벤치마크를 사용하여 당신이 하는 변경 사항이 해당 메소드의 성능에 영향을 미치는지 확인할 수 있습니다. 그러나, 벤치마크가 변경 사항이 성능을 향상시킨다고 보여도, 실제로 프로덕션 환경에서도 성능이 향상된다는 보장은 없습니다.

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

Benchmark 모듈의 또 다른 문제점은 시간을 표시하고 이터레이션을 표시하는 것입니다. 이는 코드 조각이 매우 짧은 시간에 완료될 경우, 특정 변경 사항 이전과 이후의 타이밍을 비교하는 것이 매우 어려워질 수 있습니다. 이는 다음과 같은 패턴으로 이어질 수 있습니다:

Benchmark.bmbm(10) do |bench|
  bench.report '작업' do
    100 반복 do
      ... 작업 내용 ...
    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 flamegraph로 출력할 수 있습니다.

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는 프로덕션 워크로드를 프로파일링하는 데 사용할 수 있습니다.

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

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

  • STACKPROF_ENABLED: SIGUSR2 신호에 대한 Stackprof 시그널 핸들러를 활성화합니다. 기본값은 false입니다.
  • STACKPROF_MODE: 샘플링 모드를 참조하십시오. 기본값은 cpu입니다.
  • STACKPROF_INTERVAL: 샘플링 간격입니다. 단위 의미론은 STACKPROF_MODE에 따라 다릅니다. object 모드에서는 이것이 이벤트 당 간격(모든 nth 이벤트가 샘플링됨)이며 기본값은 100입니다. cpu와 같은 다른 모드의 경우 이것은 주파수 간격이며 기본값은 10100 마이크로초(99 hz)입니다.
  • STACKPROF_FILE_PREFIX: 프로파일이 저장된 파일 경로 접두사입니다. 기본값은 $TMPDIR입니다(일반적으로 /tmp에 해당됨).
  • STACKPROF_TIMEOUT_S: 초 단위의 프로파일링 타임아웃입니다. 지나간 시간 후에 프로파일링이 자동으로 중지됩니다. 기본값은 30입니다.
  • STACKPROF_RAW: 원시 샘플 또는 집계만 수집할지 여부입니다. 원시 샘플은 플레임 그래프를 생성하는 데 필요하지만 더 많은 메모리와 디스크 오버헤드가 있습니다. 기본값은 true입니다.

활성화된 후 루비 프로세스에 SIGUSR2 신호를 보내어 프로파일링을 트리거할 수 있습니다. 프로세스는 스택을 샘플링하기 시작합니다. 프로파일링은 SIGUSR2를 다시 보내거나 타임아웃 후 자동으로 중지됩니다.

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

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

  • Puma 워커
  • Sidekiq
note
Puma 마스터 프로세스는 지원되지 않습니다. 이에 대한 SIGUSR2를 보내면 프로세스가 다시 시작됩니다. 이때 Puma의 경우 신호를 Puma 워커에만 보내도록 주의해야 합니다.

pkill -USR2 puma:를 통해 이 작업을 수행할 수 있습니다. 이때 :는 마스터 프로세스인 puma 4.3.3.gitlab.2 ...와 워커 프로세스인 puma: cluster worker 0: ...를 구분합니다.

Sidekiq의 경우 해당 신호를 pkill -USR2 bin/sidekiq-cluster를 통해 sidekiq-cluster 프로세스에 전송하여 모든 Sidekiq 자식에게 신호를 전달할 수 있습니다. 또는 관심 있는 특정 PID를 선택할 수도 있습니다.

스택프로파일 프로필 읽기

기본적으로 출력은 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의 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

Tier: Free, Premium, Ultimate

GitLab 프레임워크의 이점 (FREE ALL BETA)


ProjectPolicy#access_allowed_to?를 실행하는 샘플 수와 총 샘플 수가 표시됩니다. 이것은 테스트 스위트에서 수행된 작업과 응용 프로그램 코드 작업을 포함합니다. 따라서 이러한 프로파일을 사용하여 느린 테스트를 조사할 수 있습니다. 그러나 이 예시와 같이 작은 실행에 대해선 테스트 스위트를 설정하는 비용이 지배적일 수 있습니다.

RSpec 프로파일링

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

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

export RSPEC_PROFILING=yes
rake rspec_profiling:install

이렇게 하면 RSPEC_PROFILING 환경 변수가 설정된 상태로 테스트를 실행할 때마다 tmp/rspec_profiling에 SQLite3 데이터베이스가 생성되어 그 안에 통계가 저장됩니다.

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

$ rake rspec_profiling:console

이러한 결과는 또한 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개가 됩니다. 각각이 루비의 40바이트 슬롯에 맞기 때문에 추가 메모리가 할당되지 않습니다.

    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개가 됩니다. 각각이 루비의 40바이트 슬롯보다 크기 때문에 추가 메모리가 할당됩니다.

    Gitlab::Memory::Instrumentation.with_memory_allocations do
      s = '0'
      1_000.times { s * 24 }
    end
      
    => {:mem_objects=>1002, :mem_bytes=>32000, :mem_mallocs=>1000}
    
  • 다음 예제는 40kB 이상의 데이터를 할당하고 메모리 할당을 한 번만 수행합니다. 기존 객체는 후속 반복에서 재할당/크기 조정됩니다.

    Gitlab::Memory::Instrumentation.with_memory_allocations do
      str = ''
      append = '0123456789012345678901234567890123456789' # 40바이트
      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바이트
      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 할당된 메모리

  • 보존된 메모리: 코드 블록 실행으로 유지되는 오래 지속되는 메모리 사용 및 객체 수. 이는 메모리 및 가비지 수집기에 직접적인 영향을 미칩니다.
  • 할당된 메모리: 코드 블록에서 모든 객체 할당 및 메모리 할당. 이는 메모리에 미니멀한 영향을 미칠 수 있지만, 성능에 상당한 영향을 미칠 수 있습니다. 더 많은 객체를 할당할수록 더 많은 작업이 이루어지고 응용프로그램이 느려집니다.

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

실제 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

단편화된 루비 힙 스냅샷은 다음과 같이 보일 수 있습니다:

루비 힙 단편화

메모리 단편화는 이 게시물에 설명된대로 GC 매개변수 조정을 통해 줄일 수 있습니다. 이것은 메모리 할당 및 GC 주기의 전반적인 성능에 영향을 줄 수 있으므로 교환 고려해야 합니다.

Derailed Benchmarks

derailed_benchmarks는 “Rails 또는 루비 앱을 벤치마크하는 데 사용할 수 있는 일련의 기능”으로 설명되는 입니다. 우리는 Gemfilederailed_benchmarks를 포함합니다.

우리는 모든 test 단계가 있는 모든 파이프라인에서 memory-on-boot 작업으로 derailed exec perf:mem을 실행합니다. (예시 작업 보기.) 결과를 찾을 수 있습니다:

  • Merge Request의 개요 탭에서, Metrics Reports 드롭다운 디렉터리의 Merge Request 보고 영역에 있습니다.
  • memory-on-boot 아티팩트에서 전체 보고서 및 의존성 분석.

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

  • GDK의 경우, 젬 페이지에서 이 지침을 따라야 합니다. 오류를 피하기 위해 Redis 구성도 동일해야 합니다.
  • GCK는 production 구성 섹션이 기본으로 포함되어 있습니다.

변경의 중요성

성능 향상 작업을 수행할 때, 언제나 “이 코드 조각의 성능을 향상시키는 것이 얼마나 중요한가?”라는 질문을 항상 해야 합니다. 모든 코드 조각이 동등한 중요하지는 않으며, 사용자의 소수에만 영향을 미치는 것을 개선하려는 것은 낭비입니다. 예를 들어 특정 메서드에서 10밀리초를 끄집어내기 위해 일주일을 낭비하는 것은, 다른 곳에서 10초를 끄집어낼 수 있는데 그 시간을 낭비한 것입니다.

특정 코드 조각을 최적화할 가치가 있는지 판단하는 뚜렷한 단계 세트가 없습니다. 판단할 수 있는 유일한 두 가지는:

  1. 코드가 하는 일, 사용 방법, 호출 빈도 및 총 실행 시간(예: 웹 요청의 총 시간)에 상대적으로 소비되는 시간을 생각해보기
  2. 다른 사람들에게 물어보기 (가능하면 이슈로 요청하기)

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

  • 이중 따옴표를 작은 따옴표로 교체하기.
  • 값 디렉터리이 매우 작을 때 Array의 사용을 Set으로 대체하는 것.
  • 라이브러리 A와 라이브러리 B 중 어떤 것이 실행 시간의 0.1%를 차지할 때 교체하기.
  • 모든 문자열에 freeze를 호출하기(자세한 내용은 문자열 동결 참조).

느린 작업 및 Sidekiq

브랜치 Merge과 같이 느린 작업이나 (외부 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

캐싱

많은 경우 동일한 결과를 반환하는 작업은 Redis를 사용하여 캐시해야 합니다. 특히 Git 작업에서 그렇습니다. Redis에 데이터를 캐시할 때에는 필요할 때마다 캐시가 플러시되도록 확인해야 합니다. 예를 들어, 태그 디렉터리의 캐시는 새로운 태그가 푸시되거나 태그가 제거될 때마다 플러시되어야 합니다.

리포지터리에 캐시 만료 코드를 추가할 때에는 이 코드를 Repository 클래스에 있는 before/after 후크 중 하나에 배치해야 합니다. 예를 들어, 리포지터리를 가져온 후에 캐시가 플러시되어야 한다면 이 코드는 Repository#after_import에 추가되어야 합니다. 이렇게 하면 캐시 논리가 다른 클래스에 누출되는 것이 아니라 Repository 클래스 내에 유지됩니다.

데이터를 캐싱할 때에는 결과를 인스턴스 변수에도 메모이제이션(memoize)하는 것이 좋습니다. 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

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

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

다음 출력을 생성합니다

--> FullPipeline에 대한 MarkdownFilter 벤치마킹 중
Warming up --------------------------------------
            Markdown   271.000  i/100ms
Calculating -------------------------------------
            Markdown      2.584k (±16.5%) i/s -     23.848k in  10.042503s

파일 및 다른 데이터 소스에서 읽기

루비는 파일 내용이나 일반적인 I/O 스트림을 다루는 여러 유용한 함수를 제공합니다. IO.readIO.readlines와 같은 함수들은 데이터를 메모리로 읽는 것을 쉽게 만들지만, 데이터가 커질 때 비효율적일 수 있습니다. 왜냐하면 이러한 함수들은 데이터 소스의 전체 내용을 메모리로 읽기 때문에 메모리 사용이 데이터 소스의 크기만큼 증가합니다. readlines의 경우, 각 줄을 나타내기 위해 루비 VM이 수행해야 하는 추가 작업 때문에 더 많이 증가합니다.

디스크에 750 MB인 텍스트 파일을 읽는 다음 프로그램을 고려해보세요.

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

다음은 프로그램이 실행되는 동안 프로세스 메모리 읽기의 몇몇 부분을 보여줍니다. 우리는 실제로 전체 파일을 메모리에 유지했음을 확인할 수 있습니다 (킬로바이트로 보고된 RSS):

$ ps -o rss -p <pid>

RSS
783436

그리고 가비지 수집자가 무엇을 하고 있는지의 일부분은 다음과 같습니다.

pp GC.stat

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

우리는 heap_live_slots (도달 가능한 객체의 수)가 약 2.3M로 뛰었다는 것을 볼 수 있습니다. 이는 줄 단위로 파일을 읽을 때보다 대략 2차원을 더 많게 된 것입니다. 우리가 차지한 메모리뿐만 아니라 가비지 수집자(GC)가 미래 메모리 사용에 대한 기대에 따라 메모리 사용을 더 빠르게 증가시키기 위한 애플리케이션 동작도 변경되었음을 볼 수 있습니다. malloc_increase_bytes는 다음번에 메모리가 부족할 때 루비 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에 대한 비용을 아낄 수 있는 경우 루프를 일찍 빠져나오게 할 수 있는 조건이 있는 경우 선호될 수 있습니다.

안티 패턴

이것은 프로덕션 환경에 메트릭 가능하고 중요한 영향을 미치는 변경 사항이 없는 한 피해야 하는 안티 패턴의 모음입니다.

할당을 상수로 이동

오브젝트를 한 번만 할당하기 위해 상수로 저장하는 것은 성능을 향상시킬 수 있지만 보장되는 것은 아닙니다. 상수를 찾아보는 것이 런타임 성능에 영향을 미치며, 따라서 오브젝트를 직접 참조하는 대신 상수를 사용하는 것은 오히려 코드를 느리게 만들 수도 있습니다. 예를 들어:

SOME_CONSTANT = 'foo'.freeze

9000.times do
  SOME_CONSTANT
end

이것을 하는 유일한 이유는 전역 문자열의 변형을 방지하기 위함입니다. 그러나 루비에서 상수를 다시 할당할 수 있기 때문에 다른 곳에서 이것을 하는 것을 막는 것은 없습니다:

SOME_CONSTANT = 'bar'

백만 개의 행으로 데이터베이스 씨드하는 방법

상대적인 쿼리 성능을 비교하거나 버그를 재현하기 위해 로컬 데이터베이스에 백만 개의 프로젝트 행이 필요할 수 있습니다. SQL 명령문이나 Rails 모델 대량 삽입 기능을 사용하여 매뉴얼으로 수행할 수 있습니다.

ActiveRecord 모델과 작업하는 경우 다음 링크가 도움이 될 수 있습니다. - 일괄적으로 레코드 삽입 - BulkInsert 젬 - ActiveRecord::PgGenerateSeries 젬

예제

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

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. DeployKey 모델 생성 중 FactoryBot 팩토리는 스펙에서 트랜잭션을 건너뛰게 합니다.

비-스펙 또는 비-픽스처 파일에서의 Gitlab::ExclusiveLease.skipping_transaction_check 사용에는 계획에서 제거할 계획이 있는 인프라 데브 이슈에 대한 링크를 포함해야 합니다.