Computer >> 컴퓨터 >  >> 프로그램 작성 >> Ruby

메모이제이션으로 Rails 속도 향상

응용 프로그램을 개발할 때 느리게 실행되는 메서드가 있는 경우가 많습니다. 아마도 그들은 데이터베이스를 쿼리하거나 외부 서비스에 접근해야 할 필요가 있습니다. 둘 다 속도를 늦출 수 있습니다. 데이터가 필요할 때마다 메서드를 호출하고 오버헤드만 수락할 수 있지만 성능이 문제인 경우 몇 가지 옵션이 있습니다.

하나는 데이터를 변수에 할당하고 재사용할 수 있으므로 프로세스 속도가 빨라집니다. 가능한 솔루션이지만 해당 변수를 수동으로 관리하는 것은 금방 지루해질 수 있습니다.

그러나 대신 이 "느린 작업"을 수행하는 메서드가 해당 변수를 처리할 수 있다면 어떨까요? 이렇게 하면 동일한 방식으로 메서드를 호출할 수 있지만 메서드가 데이터를 저장하고 재사용하도록 합니다. 이것이 바로 메모이제이션이 하는 일입니다.

간단히 말해서 메모이제이션은 메서드의 반환 값을 저장하므로 매번 다시 계산할 필요가 없습니다. 모든 캐싱과 마찬가지로 메모리를 시간과 효율적으로 거래하고 있습니다(즉, 값을 저장하는 데 필요한 메모리는 포기하지만 메서드를 처리하는 데 필요한 시간은 절약할 수 있습니다).

값을 메모하는 방법

Ruby는 or-equals 연산자를 사용하여 값을 메모하는 매우 깔끔한 관용구를 제공합니다. ||= . 이것은 논리적 OR(|| ) 왼쪽 값과 오른쪽 값 사이에 결과를 왼쪽에 있는 변수에 할당합니다. 실행 중:

value ||= expensive_method(123)

#logically equivalent to:
value = (value || expensive_method(123))

메모이제이션 작동 방식

이것이 어떻게 작동하는지 이해하려면 "거짓" 값과 지연 평가라는 두 가지 개념을 이해해야 합니다. 먼저 진실-거짓부터 시작하겠습니다.

진실과 거짓

Ruby(거의 모든 다른 언어와 마찬가지로)에는 부울 true에 대한 기본 제공 키워드가 있습니다. 및 false 가치. 예상대로 정확하게 작동합니다.

if true
  #we always run this
end

if false
  # this will never run
end

그러나 Ruby(및 다른 많은 언어)에는 "truthy" 및 "falsey" 값의 개념도 있습니다. 이는 값이 true인 "인양" 처리될 수 있음을 의미합니다. 또는 false . Ruby nilfalse 거짓이다. 다른 모든 값(0 포함)은 true로 처리됩니다. (참고:다른 언어는 다른 선택을 합니다. 예를 들어 C는 0을 false로 취급합니다. ). 위의 예를 다시 사용하여 다음과 같이 작성할 수도 있습니다.

value = "abc123" # a string
if value
  # we always run this
end

value = nil
if value
  # this will never run
end

지연 평가

지연 평가는 프로그래밍 언어에서 매우 일반적인 최적화의 한 형태입니다. 프로그램이 필요하지 않은 작업을 건너뛸 수 있도록 합니다.

논리 OR 연산자(|| )은 좌변 또는 우변이 참이면 참을 반환합니다. 즉, 왼쪽 인수가 true이면 결과가 true일 것임을 이미 알고 있으므로 오른쪽을 평가하는 것은 의미가 없습니다. 이것을 직접 구현하면 다음과 같이 될 수 있습니다.

def logical_or (lhs, rhs)
  return lhs if lhs

  rhs
end

lhs인 경우 및 rhs 함수(예:lamdas)인 경우 rhs를 볼 수 있습니다. lhs인 경우에만 실행됩니다. 거짓입니다.

또는 같음

진실-거짓 값과 지연 평가의 이 두 개념을 결합하면 ||= 운영자가 하는 일:

value #defaults to nil
value ||= "test"
value ||= "blah"
puts value
=> test

nil 값으로 시작합니다. 초기화되지 않았기 때문입니다. 다음으로 첫 번째 ||=를 만납니다. 운영자. value 이 단계에서는 거짓이므로 오른쪽("test" ) 결과를 value에 할당 .이제 두 번째 ||=를 쳤습니다. 연산자이지만 이번에는 value "test" 값이 있으므로 진실됨 . 우변 평가를 건너뛰고 value로 계속 진행합니다. 손대지 않았습니다.

메모이제이션 사용 시기 결정

메모이제이션을 사용할 때 스스로에게 물어봐야 할 몇 가지 질문이 있습니다. 얼마나 자주 값에 액세스하는가? 무엇이 그것을 변화시키는가? 얼마나 자주 변경되나요?

값에 한 번만 액세스하면 값을 캐싱하는 것이 그다지 유용하지 않을 것입니다. 값에 더 자주 액세스할수록 캐싱에서 더 많은 이점을 얻을 수 있습니다.

변경의 원인에 관해서는 메서드에 어떤 값이 사용되었는지 살펴볼 필요가 있습니다. 인수가 필요합니까? 그렇다면 메모이제이션은 이를 고려해야 할 것입니다. 개인적으로, 나는 당신을 위해 논쟁을 처리하기 때문에 이것을 위해 memoist gem을 사용하는 것을 좋아합니다.

마지막으로 값이 얼마나 자주 변경되는지 고려해야 합니다. 변경하게 만드는 인스턴스 변수가 있습니까? 캐시된 값이 변경되면 지워야 합니까? 값을 개체 수준에서 캐시해야 하나요 아니면 클래스 수준에서 캐시해야 하나요?

이 질문에 답하기 위해 간단한 예를 보고 결정을 단계별로 살펴보겠습니다.

class ProfitLossReport
  def initialize(title, expenses, invoices)
    @expenses = expenses
    @invoices = invoices
    @title = title
  end

  def title
    "#{@title} #{Time.current}"
  end

  def cost
    @expenses.sum(:amount)
  end

  def revenue
    @invoices.sum(:amount)
  end

  def profit
    revenue - cost
  end

  def average_profit(months)
    profit / months.to_f
  end
end

호출 코드는 여기에 표시되지 않지만 title 메소드는 아마도 한 번만 호출되며 Time.current도 사용합니다. 따라서 메모하면 값이 즉시 유효하지 않게 될 수 있습니다.

revenuecost 메서드는 이 클래스 내에서도 여러 번 적중됩니다. 둘 다 데이터베이스에 적중해야 한다는 점을 감안할 때 성능이 문제가 될 경우 메모이징의 주요 후보가 될 것입니다. 이것들을 메모한다고 가정하고 profit 메모화할 필요가 없습니다. 그렇지 않으면 최소한의 이득을 위해 캐싱 위에 캐싱을 추가할 뿐입니다.

마지막으로 average_profit이 있습니다. . 여기서의 값은 인수에 의존하므로 메모이징은 이를 고려해야 합니다. revenue과 같은 간단한 경우 우리는 이것을 할 수 있습니다:

def revenue
  @revenue ||= @invoices.sum(:amount)
end

average_profit의 경우 하지만 전달되는 각 인수에 대해 다른 값이 필요합니다. 이를 위해 memoist를 사용할 수 있지만 명확성을 위해 여기에 자체 솔루션을 적용하겠습니다.

def average_profit(months)
  @average_profit ||= {}
  @average_profit[months] ||= profit / months.to_f
end

여기에서 해시를 사용하여 계산된 값을 추적합니다. 먼저 @average_profit을 확인합니다. 초기화된 다음 전달된 인수를 해시 키로 사용합니다.

클래스 수준 또는 인스턴스 수준에서 메모하기

대부분의 시간 메모화는 인스턴스 수준에서 수행됩니다. 즉, 인스턴스 변수를 사용하여 계산된 값을 유지합니다. 이것은 또한 우리가 객체의 새 인스턴스를 생성할 때마다 "캐시된" 값의 이점을 얻지 못한다는 것을 의미합니다. 다음은 매우 간단한 예시입니다.

class MemoizedDemo
  def value
    @value ||= computed_value
  end

  def computed_value
    puts "Crunching Numbers"
    rand(100)
  end
end

이 개체를 사용하여 결과를 볼 수 있습니다.

demo = MemoizedDemo.new
=> #<MemoizedDemo:0x00007f95e5d9d398>

demo.value
Crunching Numbers
=> 19

demo.value
=> 19

MemoizedDemo.new.value
Crunching Numbers
=> 93

간단히 클래스 수준 변수( @@ 포함)를 사용하여 이를 변경할 수 있습니다. ) 메모화된 값:

  def value
    @@value ||= computed_value
  end

결과는 다음과 같습니다.

demo = MemoizedDemo.new
=> #<MemoizedDemo:0x00007f95e5d9d398>
demo.value
Crunching Numbers
=> 60
demo.value
=> 60
MemoizedDemo.new.value
=> 60

클래스 수준 메모이제이션을 자주 원하지 않을 수도 있지만 옵션으로 있습니다. 그러나 이 수준에서 값을 캐시해야 하는 경우 Redis 또는 memcached와 같은 외부 저장소를 사용하여 값을 캐시하는 것이 좋습니다.

Ruby on Rails 애플리케이션의 일반적인 메모 사용 사례

Rails 애플리케이션에서 메모이제이션에 대한 가장 일반적인 사용 사례는 특히 단일 요청 내에서 값이 변경되지 않을 때 데이터베이스 호출을 줄이는 것입니다. 컨트롤러에서 레코드를 찾기 위한 "Finder" 메서드는 다음과 같은 데이터베이스 호출의 좋은 예입니다.

  def current_user
    @current_user ||= User.find(params[:user_id])
  end

또 다른 일반적인 장소는 뷰를 렌더링하기 위해 일종의 데코레이터/프레젠터/뷰 모델 유형의 아키텍처를 사용하는 경우입니다. 이러한 개체의 메서드는 요청 수명 동안만 지속되고 데이터는 일반적으로 변경되지 않으며 일부 메서드는 보기를 렌더링할 때 여러 번 적중되기 때문에 메모이제이션에 대한 좋은 후보가 있는 경우가 많습니다.

메모화 문제

가장 큰 문제 중 하나는 실제로 필요하지 않은 항목을 메모하는 것입니다. 문자열 보간과 같은 것은 메모이제이션을 위한 쉬운 후보처럼 보일 수 있지만 실제로는 사이트 성능에 눈에 띄는 영향을 미치지 않을 것입니다(물론 예외적으로 큰 문자열을 사용하거나 매우 많은 양의 문자열 조작을 수행하지 않는 한). 예:

  def title
    # memoization here is not going to have much of an impact on our performance
    @title ||= "#{@object.published_at} - #{@object.title}"
  end

주의해야 할 또 다른 사항은 특히 메모화된 값이 개체의 상태에 따라 달라지는 경우 오래된 친구 캐시 무효화입니다. 이를 방지하는 한 가지 방법은 가능한 가장 낮은 수준에서 캐시하는 것입니다. 메소드를 캐싱하는 대신 a + b a를 캐시하는 것이 더 나을 수 있습니다. 및 b 방법을 개별적으로.

  # Instead of this
  def profit
    # anyone else calling 'revenue' or 'losses' is not benefitting from the caching here
    # and what happens if the 'revenue' or 'losses' value changes, will we remember to update profit?
    @profit ||= (revenue - losses)
  end

  # try this
  def profit
    # no longer cached, but subtraction is a fast calculation
    revenue - losses
  end

  def revenue
    @revenue ||= Invoice.all.sum(:amount)
  end

  def losses
    @losses ||= Purchase.all.sum(:amount)
  end

마지막 문제는 게으른 평가가 작동하는 방식 때문입니다. ||=와 같이 잘못된 값(예:nil 또는 false)을 메모해야 하는 경우 약간 더 사용자 정의해야 합니다. 저장된 값이 거짓이면 관용구는 항상 오른쪽을 실행합니다. 내 경험상 이러한 값을 캐시해야 하는 경우는 많지 않지만 캐시해야 할 경우 부울 플래그를 추가하여 이미 계산되었음을 나타내거나 다른 캐싱 메커니즘을 사용해야 할 수 있습니다.

  def last_post
    # if the user has no posts, we will hit the database every time this method is called
    @last_post ||= Post.where(user: current_user).order_by(created_at: :desc).first
  end

  # As a simple workaround we could do something like:
  def last_post
    return @last_post if @last_post_checked

    @last_post_checked = true
    @last_post ||= Post.where(user: current_user).order_by(created_at: :desc).first
  end

메모라이제이션이 충분하지 않을 때

메모이제이션은 애플리케이션의 일부에서 성능을 향상시키는 저렴하고 효과적인 방법이 될 수 있지만 단점이 없는 것은 아닙니다. 한 가지 큰 것은 끈기입니다. 일반적인 인스턴스 수준 메모이제이션의 경우 값은 하나의 특정 개체에 대해서만 저장됩니다. 이것은 웹 요청의 수명 동안 값을 저장하는 데 메모이제이션을 훌륭하게 만들지만 여러 요청에 대해 동일하고 매번 다시 계산되는 값이 있는 경우 캐싱의 모든 이점을 제공하지 않습니다.

클래스 수준 메모이제이션이 도움이 될 수 있지만 캐시 무효화를 관리하기가 더 어려워집니다. 서버를 재부팅하면 캐시된 값이 손실되고 여러 웹 서버에서 공유할 수 없습니다.

캐싱에 대한 이 시리즈의 다음 호에서는 이러한 문제에 대한 Rails의 솔루션인 저수준 캐싱을 살펴보겠습니다. 서버 간에 공유할 수 있는 외부 저장소에 값을 캐시하고 만료 시간 초과 및 동적 캐시 키를 사용하여 캐시 무효화를 관리할 수 있습니다.