성능 가이드라인

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

성능 문서

Workflow

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

  1. 어딘가에 이슈가 열려 있는지 확인하고(예: GitLab CE 이슈 트래커), 이슈가 없으면 생성하세요. 예시로 #15607를 참조하세요.
  2. Production 환경(예: GitLab.com)에서 코드의 성능을 메트릭합니다(도구 섹션 참조). 성능은 적어도 24시간 동안 메트릭되어야 합니다.
  3. 메트릭 기간에 대한 결과를 해당 이슈에 추가하세요(그래프 스샇샷, 시간 등).
  4. 문제를 해결하세요.
  5. Merge Request을 생성하고 “성능” 레이블을 할당하고 성능 리뷰 프로세스를 따르세요.
  6. 변경 내용을 배포한 후 다시 적어도 24시간 동안 메트릭하여 제품 환경에 변경 내용이 어떤 영향을 미치는지 확인하세요.
  7. 완료될 때까지 반복하세요.

타이밍을 제공할 때는 다음을 반드시 제공해야 합니다:

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

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

Tooling

GitLab은 성능 및 가용성을 개선하는데 도움이 되는 내장 도구를 제공합니다:

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

벤치마크

대부분의 경우 벤치마크는 거의 쓸모가 없습니다. 벤치마크는 일반적으로 코드의 일부분만을 격리시켜 테스트하며 종종 최상의 경우만을 메트릭합니다. 또한 라이브러리(예: 젬)에 대한 벤치마크는 종종 해당 라이브러리를 선호하도록 편향되어 있습니다. 결국 경쟁 상대보다 성능이 떨어진 것을 보여주는 벤치마크를 게시하는 것은 저자에게는 거의 이익이 없습니다.

벤치마크는 대부분 당신의 변경 내용이 어떤 영향을 미치는지 대략적으로(강조는 “대략적으로”) 이해해야 할 때에만 유용합니다. 예를 들어 특정 메소드가 느리다면 벤치마크를 사용하여 당신의 변경 내용이 해당 메소드의 성능에 어떤 영향을 미치는지 확인할 수 있습니다. 그러나 심지어 벤치마크가 당신의 변경 내용이 성능을 개선한다고 보여준다고 해도, 제품 환경에서 성능도 개선된다는 보장은 없습니다.

벤치마크를 작성할 때는 대부분 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 젬은 이 모든 것과 그 이상을 처리합니다. 따라서 Benchmark 모듈 대신에 사용해야 합니다.

또한 GitLab Gemfile에는 benchmark-memory 젬이 포함되어 있으며, benchmark-memorybenchmarkbenchmark-ips 젬과 유사하게 작동합니다. 그러나 benchmark-memory는 대신 벤치마크 실행 중 할당된 및 유지된 메모리 크기 및 객체 및 문자열에 대한 정보를 반환합니다.

요약하면:

  • 인터넷에서 찾은 벤치마크를 신뢰하지 마십시오.
  • 항상 제품에서 결과를 확인하기 위해 단순히 벤치마크만을 기반으로 주장하지 마십시오.
  • X가 Y보다 N배 빠르다는 것은 당신이 해당 성능이 제품 환경에 어떤 영향을 미치는지 알지 못한다면 의미가 없습니다.
  • 제품 환경은 항상 진실을 말하는 유일한 벤치마크입니다 (성능 모니터링 시스템이 올바르게 설정되지 않았는 한).
  • 반드시 벤치마크를 작성해야 하는 경우 표준 라이브러리에 포함된 Ruby의 Benchmark 모듈 대신 benchmark-ips 젬을 사용해야 합니다.

Stackprof로 프로파일링

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

프로파일링은 애플리케이션을 변경합니다. 서로 다른 프로파일링 전략은 서로 다른 오버헤드를 가지고 있습니다. Stackprof는 샘플링 프로파일러입니다. 설정 가능한 주파수(예: 100hz, 즉 초당 100스택)에서 실행 중인 스레드에서 스택 추적을 샘플링합니다. 이 유형의 프로파일링에는 상당한(하지만 0이 아닌) 오버헤드가 있지만 일반적으로 production에서 사용하기에 안전하다고 여겨집니다.

프로파일러는 개발 중에서도 매우 유용한 도구일 수 있지만, 대표적인 환경에서 실행됩니다. 특히 어떤 메소드가 여러 번 실행되거나 실행 시간이 오래걸리는 경우에도 필요한 것은 아닙니다. 프로파일은 애플리케이션에서 무슨 일이 일어나고 있는지를 이해하는 데 사용할 수 있는 도구입니다. 그 정보를 현명하게 사용하는 것은 당신에게 달려 있습니다!

Stackprof로 프로파일을 만드는 여러 가지 방법이 있습니다.

코드 블록 래핑

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

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

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

Tier: Free, Premium, Ultimate


RSpec 프로파일링

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

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

export RSPEC_PROFILING=yes
rake rspec_profiling:install

이렇게 하면 RSPEC_PROFILING 환경 변수를 설정하여 spec을 실행할 때마다 통계가 저장되는 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에서 객체와 메모리가 할당되는 다른 측면을 나타냅니다:

  • 다음 예제는 문자열이 얼릴 수 있기 때문에 대략 1000mem_objects를 생성합니다. 기본 문자열 개체는 같지만 여전히 이 문자열에 대한 1000개의 참조를 할당해야 합니다:

    Gitlab::Memory::Instrumentation.with_memory_allocations do
      1_000.times { '0123456789' }
    end
      
    => {:mem_objects=>1001, :mem_bytes=>0, :mem_mallocs=>0}
    
  • 다음 예제는 문자열이 동적으로 생성되기 때문에 대략 1000mem_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}
    
  • 다음 예제는 문자열이 동적으로 생성되기 때문에 대략 1000mem_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,000개 이상의 개체를 생성하고 1,000개 이상의 할당을 수행하며 각 반복마다 개체를 변경합니다. 이는 많은 데이터를 복사하고 많은 메모리 할당을 수행하기 때문에 매우 비효율적인 문자열 추가 방법을 나타냅니다 (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에 포함되어 있으며, 필요한 경우에는 require만 해주면 됩니다. 이것은 환경 변수를 설정하여 웹서버나 Sidekiq를 실행함으로써 달성할 수 있습니다.

힙 덤프 내용은 다음과 같습니다:

bundle exec rbtrace -p <PID> -e 'File.open("heap.json", "wb") { |t| ObjectSpace.dump_all(output: t) }'

JSON을 소유하고 있다면, Aaron이 제공한 스크립트를 사용하여 그림을 렌더링할 수 있습니다.

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

루비 힙 단편화

메모리 단편화는 이 포스트에 설명된대로 GC 매개변수를 조정하여 줄일 수 있습니다. 이것은 메모리 할당 및 GC 주기의 전반적인 성능에 영향을 줄 수 있으므로 이것은 상충 관계로 여겨져야 합니다.

Derailed Benchmarks

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

모든 파이프라인의 test 단계에서 memory-on-boot이라는 작업에서 derailed exec perf:mem을 실행합니다. (예시 작업을 확인하세요..) 결과를 다음 위치에서 찾을 수 있습니다:

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

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

변경 사항의 중요성

성능 개선 작업 중요성에 대해 항상 “이 코드의 성능을 개선하는 것이 얼마나 중요한가?”라는 질문을 하는 것이 중요합니다. 모든 코드 조각이 동일하게 중요한 것은 아니며, 사용자 중 일부에만 미치는 작은 영향을 개선하려고 일주일을 낭비하는 것은 시간 낭비입니다. 예를 들어, 메서드에서 10밀리초를 더 줄이려고 일주일을 소비하는 것은 다른 곳에서 10초를 더 줄일 수 있는데 그냥 하는 것입니다.

특정 코드 조각을 최적화할 가치가 있는지 결정하는 단계가 명확히 있는 것은 아닙니다. 할 수 있는 두 가지만 있습니다:

  1. 코드가 하는 일, 사용 방법, 호출 횟수 및 총 실행 시간(예: 웹 요청의 총 시간)에 상대적으로 얼마나 많은 시간이 사용되는지에 대해 생각하십시오.
  2. 기타 사람들에게 (가능하면 이슈 형태로) 물어보십시오.

일부 중요하지 않거나 노력할 가치가 없는 변경 사례는 다음과 같습니다:

  • 이중 따옴표를 단일 따옴표로 바꾸는 것.
  • 값 디렉터리이 매우 작을 때 Array 사용을 Set으로 바꾸는 것.
  • 라이브러리 A와 라이브러리 B를 서로 바꾸는 것이 각각 전체 실행 시간의 0.1% 밖에 사용하지 않을 때.
  • 모든 문자열에 freeze를 호출하는 것(참조: String Freezing).

느린 작업 및 Sidekiq

Merge 브랜치와 같이 느린 작업 또는 외부 API를 사용하는 작업은 가능한 경우 웹 요청 대신 Sidekiq 워커에서 수행해야 합니다. 이렇게 하는 것은 여러 이점이 있습니다.

  1. 오류가 요청의 완료를 방해하지 않습니다.
  2. 프로세스가 느린 것이 페이지 로딩 시간에 영향을 미치지 않습니다.
  3. 실패할 경우 프로세스를 재시도할 수 있습니다(Sidekiq가 자동으로 처리합니다).
  4. 웹 요청에서 코드를 격리시킴으로써 테스트 및 유지보수가 더 쉬워집니다.

특히 하부 저장 시스템의 성능에 따라 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 바이트 (x64 아키텍처에서 40바이트)의 메모리를 사용하여 메모리를 절약할 수 있습니다.

.freeze를 사용하여 잠재적으로 .freeze로 이점을 얻을 수 있는 자주 할당되는 문자열을 보려면 메모리 프로파일러를 사용할 수 있습니다.

Ruby 3.0에서는 기본적으로 문자열이 고정화됩니다. 이를 대비하여 코드베이스를 준비하기 위해 다음 헤더를 모든 Ruby 파일에 추가하고 있습니다:

# frozen_string_literal: true

이로 인해 문자열을 조작할 수 있는 코드에서 테스트 실패가 발생할 수 있습니다. dup 대신에 비 고정화된 문자열을 얻으려면 단항 플러스를 사용하세요:

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

새로운 Ruby 파일을 추가할 때, 위의 헤더를 추가할 수 있는지 확인하여 스타일 체크 실패를 방지하세요.

Banzai 파이프라인 및 필터

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

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

bin/rake benchmark:banzai

이 명령은 다음과 유사한 출력을 생성합니다:

--> Full, Wiki 및 Plain 파이프라인 벤치마킹
계산 중 -------------------------------------
       Full 파이프라인     1.000  i/100ms
       Wiki 파이프라인     1.000  i/100ms
      Plain 파이프라인     1.000  i/100ms
-------------------------------------------------
       Full 파이프라인      3.357  (±29.8%) i/s -     31.000
       Wiki 파이프라인      2.893  (±34.6%) i/s -     25.000  in  10.677014초
      Plain 파이프라인     15.447  (±32.4%) i/s -    119.000

비교:
      Plain 파이프라인:       15.4 i/s
       Full 파이프라인:        3.4 i/s - 4.60배 느림
       Wiki 파이프라인:        2.9 i/s - 5.34배 느림

.
--> FullPipeline 필터 벤치마킹
계산 중 -------------------------------------
            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.617882초

비교:
          Suggestion:   739616.9 i/s
               Kroki:   306449.0 i/s - 2.41배 느림
InlineGrafanaMetrics:   156535.6 i/s - 4.72배 느림
        SetDirection:     3728.3 i/s - 198.38배 느림

...
       
       UserReference:        2.1 i/s - 360365.80배 느림
        ExternalLink:        1.6 i/s - 470400.67배 느림
    ProjectReference:        0.7 i/s - 1128756.09배 느림

.
--> PlainMarkdownPipeline 필터 벤치마킹
계산 중 -------------------------------------
            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.042503초

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

Ruby에는 파일 내용을 처리하는 여러 편의 기능이 제공됩니다. IO.readIO.readlines와 같은 함수를 사용하면 데이터를 메모리에 쉽게 읽을 수 있지만, 데이터가 커지면 비효율적일 수 있습니다. 이러한 함수는 데이터 소스의 전체 내용을 메모리에 읽기 때문에 메모리 사용량이 적어도 데이터 소스의 크기만큼 증가합니다. readlines의 경우 각 라인을 나타내기 위해 루비 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)도 변경되었음을 의미합니다. 우리가 메모리를 더 많이 점유했을 뿐만 아니라 애플리케이션의 동작 방식도 더 빠른 속도로 메모리 사용을 증가시키기 위해 애플리케이션이 변화했습니다.

IO.read 함수는 각 줄 객체에 대해 추가 메모리가 할당됨을 예측할 수 있게끔 유사한 동작을 합니다.

권장 사항

데이터 원본을 모두 메모리에 읽는 대신, 가능하면 줄 단위로 읽는 것이 좋습니다. 이것이 항상 옵션이 되는 것은 아니며, 예를 들어 YAML 파일을 루비 Hash로 변환해야 하는 경우에는 선택할 수 없을 수 있습니다. 그러나 각 행이 어떤 엔티티를 나타내고 처리된 다음 버려질 수 있는 데이터가 있는 경우에는 다음 접근 방법을 사용할 수 있습니다.

먼저, 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을 변형하는 것을 방지하는 것입니다. 그러나 루비에서는 단순히 상수를 재할당할 수 있기 때문에 다른 곳에서도 이것을 할 수 있는 것을 막을 수 있는 것은 없습니다:

SOME_CONSTANT = 'bar'

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

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

ActiveRecord 모델을 사용하는 경우 다음 링크들이 도움이 될 수 있습니다:

예시

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

ExclusiveLease

Gitlab::ExclusiveLease는 분산된 서버 전체에서 상호 배제를 달성할 수 있도록 하는 기반 Redis 잠금 메커니즘입니다. 개발자들이 사용할 수 있는 여러 래퍼가 있습니다:

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

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

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

  1. Users::Internallet_it_be에서 봇 생성용으로 트랜잭션 확인을 건너뛰기 위해 패치되었습니다.
  2. FactoryBot:deploy_key에 대한 DeployKey 모델 생성 중에 트랜잭션을 건너뛰는 것이 스펙에서만 가능하며, 삭제할 계획을 위한 infradev 이슈 링크를 포함해야 합니다.