Использование памяти Ruby: методический цикл по сравнению со встроенным циклом

#ruby #loops #memory

#ruby #циклы #память

Вопрос:

Возьмите эту программу Ruby (MRI):

 @n = 0
loop do
    @n  = 1
    break if @n == 11_900
end

ps = `ps auxm | grep ruby`
puts "Memory usage: #{ps.split[5].to_i/1024.0} Mb"
  

Используя встроенную loop функцию, она бесконечно выполняет цикл, пока @n значение не будет равно 11 900, затем печатает память, используемую Ruby в процессе (я использовал системный вызов из-за отсутствия хорошего рабочего профилировщика памяти).

При выполнении выводится: Memory usage: 9.16796875 Mb или где-то между 8,99 Мб и 9,49 Мб.

Сравните с этой функцией:

 @n = 0
def lp
  @n  = 1
  if @n == 11_900
    return
  end
  lp
end
lp
  

Используя самозваную функцию, lp она выполняет цикл до @n тех пор, пока значение не будет равно 11 900 (предел стека).

При выполнении выводится: Memory usage: 10.20703125 Mb или где-то между 9,96 Мб и 10,41 Мб.

Почему первая программа занимает почти на мегабайт меньше памяти, чем вторая? Чем встроенный цикл отличается от искусственного цикла?

Единственная причина, о которой я могу думать с моими ограниченными знаниями, заключается в том, что loop функция компилируется непосредственно в C, тогда как вторая программа имеет много накладных расходов с определением функции и т.д.

Ответ №1:

Ваш первый цикл является итеративным, а второй — рекурсивным.

Другими словами, вы вызываете один и тот же метод из самого себя снова и снова. Это создает огромный стек. Вы можете увидеть это, вызвав исключение в заданной точке:

 @n = 0
def lp
  @n  = 1
  raise if @n >= 10
  lp
end
lp
  

Дает:

 loop.rb:4:in `lp': unhandled exception
        from loop.rb:5:in `lp'
        from loop.rb:5:in `lp'
        from loop.rb:5:in `lp'
        from loop.rb:5:in `lp'
        from loop.rb:5:in `lp'
        from loop.rb:5:in `lp'
        from loop.rb:5:in `lp'
        from loop.rb:5:in `lp'
        from loop.rb:5:in `lp'
        from loop.rb:7:in `<main>'
  

Вы используете особый вид рекурсии, называемый здесь хвостовой рекурсией, и его можно оптимизировать.

Хотя Ruby не оптимизирует конечные вызовы по умолчанию, вы можете включить это вручную:

 # tailcall.rb

tailcall = ARGV.include?('-t')

RubyVM::InstructionSequence.compile_option = {
  tailcall_optimization: tailcall,
  trace_instruction: false
}

RubyVM::InstructionSequence.new(<<-RUBY).eval
  @n = 0
  def lp
    @n  = 1
    if @n == 11_900
      return
    end
    lp
  end
  lp
RUBY

puts "Memory usage: #{`ps -o rss= -p #{$$}`.to_i} kB"
  

Из оболочки без оптимизации:

 $ ruby --disable-all tailcall.rb
Memory usage: 4952 kB
  

и с оптимизацией:

 $ ruby --disable-all tailcall.rb -t
Memory usage: 3860 kB
  

Использование оптимизации конечных вызовов делает рекурсивный алгоритм таким же эффективным по объему памяти, как и его итеративный аналог. Это также предотвращает переполнение стека.

Ответ №2:

В вашем рекурсивном подходе для вызова новой функции должны быть выделены дополнительные сегменты памяти. В то время как в вашем итеративном подходе все значения, необходимые для вычисления логики, уже доступны из настройки, поэтому используется меньше памяти.