성능 가이드라인
GitLab v19.1이 문서는 GitLab의 일관된 성능을 보장하기 위한 다양한 가이드라인을 설명합니다. 임포트/익스포트 성능 문제 트러블슈팅 gitlab 프로젝트의 파이프라인 성능 gdk measure 및 gdk measure-workflow
이 문서는 GitLab의 일관된 성능을 보장하기 위한 다양한 가이드라인을 설명합니다.
성능 문서#
- 일반:
- 데이터베이스:
-
모니터링 및 개요:
-
GitLab Self-Managed 관리 및 고객 중심:
워크플로 {#workflow}#
성능 문제를 해결하는 프로세스는 대략 다음과 같습니다:
-
어딘가에(예: GitLab CE 이슈 트래커) 이슈가 열려 있는지 확인하고, 없다면 하나를 생성합니다. 예시로 #15607을 참조하세요.
-
GitLab.com과 같은 프로덕션 환경에서 코드의 성능을 측정합니다(아래 도구 섹션 참조). 성능은 최소 24시간에 걸쳐 측정해야 합니다.
-
측정 기간을 기반으로 한 결과물(그래프 스크린샷, 타이밍 등)을 1단계에서 언급한 이슈에 추가합니다.
-
문제를 해결합니다.
-
머지 리퀘스트를 생성하고 "Performance" 라벨을 할당하여 성능 리뷰 프로세스를 따릅니다.
-
변경 사항이 배포된 후에는 변경 사항이 프로덕션 환경에 영향을 미치는지 확인하기 위해 최소 24시간 동안 다시 측정해야 합니다.
-
완료될 때까지 반복합니다.
타이밍을 제공할 때 다음 사항을 포함해야 합니다:
-
95번째 백분위수
-
99번째 백분위수
-
평균
그래프 스크린샷을 제공할 때는 X축과 Y축 그리고 범례가 명확하게 보이는지 확인하세요. GitLab.com의 모니터링 도구에 접근할 수 있다면 관련 그래프/대시보드에 대한 링크도 제공해야 합니다.
도구 {#tooling}#
GitLab은 성능 및 가용성 향상을 위한 내장 도구를 제공합니다:
-
N+1회귀를 방지하기 위한 QueryRecorder. -
장애 시나리오 테스트를 위한 Chaos 엔드포인트. 주로 가용성 테스트를 위한 것입니다.
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 모듈 대신 이것을 사용해야 합니다.
GitLab Gemfile에는 benchmark-memory gem도 포함되어 있으며, 이는 benchmark 및 benchmark-ips gem과 유사하게 작동합니다. 그러나 benchmark-memory는 벤치마크 중에 할당되고 유지된 메모리 크기, 객체 및 문자열을 반환합니다.
요약하자면:
-
인터넷에서 찾은 벤치마크를 신뢰하지 마세요.
-
벤치마크만을 기반으로 주장하지 마세요. 결과를 확인하려면 항상 프로덕션에서 측정하세요.
-
Y보다 X가 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(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 worker
-
Sidekiq
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 프로파일 읽기 {#reading-a-stackprof-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가 생성하는 플레임 그래프를 보려면 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 개발 환경에는 spec 실행 시간에 대한 데이터를 수집하는 데 사용되는 rspec_profiling gem도 포함되어 있습니다. 이는 테스트 스위트 자체의 성능을 분석하거나 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에서 확인할 수 있습니다.
자체 코드의 메모리 압력 확인#
자체 코드를 측정하는 방법에는 두 가지가 있습니다:
-
메모리 할당 카운터가 포함된
api_json.log,development_json.log,sidekiq.log를 검토합니다. -
주어진 코드 블록에 대해
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 사용 {#using-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 -e 'File.open("heap.json", "wb") { |t| ObjectSpace.dump_all(output: t) }'
JSON을 확보하면 Aaron이 제공한 스크립트나 유사한 스크립트를 사용하여 그림을 렌더링할 수 있습니다:
ruby heapviz.rb heap.json
단편화된 Ruby 힙 스냅샷은 다음과 같이 보일 수 있습니다:
[
](/19.1/development/img/memory_ruby_heap_fragmentation_v12_3.png)
메모리 단편화는 이 포스트에 설명된 대로 GC 매개변수를 조정하여 줄일 수 있습니다. 이는 메모리 할당 및 GC 사이클의 전반적인 성능에 영향을 미칠 수 있으므로 트레이드오프로 고려해야 합니다.
Derailed Benchmarks#
derailed_benchmarks는 "Rails 또는 Ruby 앱을 벤치마킹하는 데 사용할 수 있는 일련의 도구"로 설명되는 gem입니다.
derailed_benchmarks를 Gemfile에 포함하고 있습니다.
test Stage가 있는 모든 파이프라인에서 derailed exec perf:mem을 memory-on-boot라는 job으로 실행합니다. (예시 job 보기.)
결과는 다음에서 찾을 수 있습니다:
-
머지 리퀘스트의 Overview 탭, 머지 리퀘스트 보고서 영역, Metrics Reports 드롭다운 목록.
-
전체 보고서 및 의존성 분류를 위한
memory-on-boot아티팩트.
derailed_benchmarks는 메모리를 조사하기 위한 다른 방법도 제공합니다. 자세한 정보는 gem 문서를 참조하세요.
대부분의 방법(derailed exec perf:*)은 production 환경에서 Rails 앱을 부팅하고 이에 대해 벤치마크를 실행하려고 시도합니다.
GDK 및 GCK 모두에서 가능합니다:
-
GDK의 경우 gem 페이지의 지침을 따르세요. 오류를 방지하려면 Redis 구성에 대해서도 유사하게 수행해야 합니다.
-
GCK에는 기본 제공
production구성 섹션이 포함되어 있습니다.
변경 사항의 중요성#
성능 개선 작업 시 항상 "이 코드 조각의 성능을 개선하는 것이 얼마나 중요한가?"라는 질문을 스스로에게 던지는 것이 중요합니다. 모든 코드가 동등하게 중요한 것은 아니며, 극히 소수의 사용자에게만 영향을 미치는 것을 개선하는 데 일주일을 보내는 것은 낭비가 됩니다. 예를 들어, 다른 곳에서 10초를 줄이는 데 일주일을 보낼 수 있었는데 메서드에서 10밀리초를 줄이려고 일주일을 보내는 것은 시간 낭비입니다.
특정 코드 조각이 최적화할 가치가 있는지 결정하기 위해 따를 수 있는 명확한 단계가 없습니다. 할 수 있는 두 가지는:
-
코드가 무엇을 하는지, 어떻게 사용되는지, 얼마나 자주 호출되는지, 총 실행 시간(예: 웹 요청에서 소요된 총 시간)에 비해 얼마나 많은 시간이 소요되는지 생각해 보세요.
-
다른 사람들에게 물어보세요(가능한 한 이슈 형태로).
실제로 중요하지 않거나 노력할 가치가 없는 변경 사항의 몇 가지 예:
-
큰따옴표를 작은따옴표로 교체.
-
값 목록이 매우 작을 때 Array 사용을 Set으로 교체.
-
둘 다 총 실행 시간의 0.1%만 차지할 때 라이브러리 A를 라이브러리 B로 교체.
-
모든 문자열에
freeze를 호출하기(문자열 동결 참조).
느린 작업 및 Sidekiq#
브랜치 병합과 같이 느린 작업이나 오류가 발생하기 쉬운 작업(외부 API 사용)은 웹 요청에서 직접 수행하는 대신 가능한 한 Sidekiq worker에서 수행해야 합니다. 이는 다음과 같은 수많은 이점이 있습니다:
-
오류가 요청 완료를 방해하지 않습니다.
-
프로세스가 느려도 페이지 로딩 시간에 영향을 미치지 않습니다.
-
실패하는 경우 프로세스를 재시도할 수 있습니다(Sidekiq가 자동으로 처리).
-
웹 요청에서 코드를 분리하면 테스트 및 유지 관리가 더 쉬워집니다.
기본 스토리지 시스템의 성능에 따라 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 클래스 내에 유지됩니다.
데이터를 캐싱할 때 결과를 인스턴스 변수에도 메모이제이션(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
문자열 동결 {#string-freezing}#
최신 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이 각 줄을 표현하기 위해 수행해야 하는 추가 북키핑으로 인해 더욱 증가합니다.
디스크에서 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'
수백만 행으로 데이터베이스 시드하는 방법#
예를 들어 상대적인 쿼리 성능을 비교하거나 버그를 재현하기 위해 로컬 데이터베이스에 수백만 개의 project 행이 필요할 수 있습니다. SQL 명령을 직접 사용하거나 Mass Inserting Rails Models 기능을 사용하여 이를 수행할 수 있습니다.
ActiveRecord 모델로 작업 중이라면 다음 링크도 도움이 될 것입니다:
예시#
이 스니펫에서 유용한 예시를 찾을 수 있습니다.
ExclusiveLease#
Gitlab::ExclusiveLease는 개발자가 분산 서버 간 상호 배타성을 달성할 수 있도록 하는 Redis 기반 잠금 메커니즘입니다. 개발자가 사용할 수 있는 여러 래퍼가 있습니다:
-
Gitlab::ExclusiveLeaseHelpers모듈은 임대를 만료할 수 있을 때까지 프로세스 또는 스레드를 차단하는 헬퍼 메서드를 제공합니다. -
ExclusiveLeaseGuard모듈은 실행 중인 코드 블록에 대한 독점 임대를 얻는 데 도움이 됩니다.
느린 Redis I/O가 유휴 트랜잭션 지속 시간을 늘릴 수 있으므로 데이터베이스 트랜잭션 내에서 ExclusiveLease를 사용해서는 안 됩니다. .try_obtain 메서드는 임대 시도가 데이터베이스 트랜잭션 내에 있는지 확인하고 Sentry와 log/exceptions_json.log에서 예외를 추적합니다.
테스트 또는 개발 환경에서 데이터베이스 트랜잭션의 임대 시도는 Gitlab::ExclusiveLease.skipping_transaction_check 블록 내에서 수행되지 않는 한 Gitlab::ExclusiveLease::LeaseWithinTransactionError를 발생시킵니다. 가능하면 스킵 기능을 spec에서만 사용하고 이해하기 쉽도록 임대에 최대한 가깝게 배치해야 합니다. spec을 DRY하게 유지하기 위해 트랜잭션 검사 스킵이 재사용되는 코드베이스의 두 부분이 있습니다:
-
Users::Internal은let_it_be에서 봇 생성에 대한 트랜잭션 검사를 건너뛰도록 패치됩니다. -
:deploy_key에 대한FactoryBotfactory는DeployKey모델 생성 중 트랜잭션을 건너뜁니다.
비-spec 또는 비-픽스처 파일에서 Gitlab::ExclusiveLease.skipping_transaction_check를 사용하면 이를 제거하기 위한 계획에 대한 infradev 이슈 링크가 포함되어야 합니다.