Сборщик мусора в Ruby 2.2 провоцирует неожиданный CoW

Как я могу предотвратить создание GC-кода при копировании на запись, когда я перепрограммирую свой процесс? Я недавно анализировал поведение сборщика мусора в Ruby из-за некоторых проблем с памятью, с которыми я столкнулся в своей программе (у меня закончилась нехватка памяти на моем 60-битном процессоре 0,5 Тбайта даже для довольно небольших задач). Для меня это действительно ограничивает полезность ruby ​​для запуска программ на многоядерных серверах. Я хотел бы представить здесь свои эксперименты и результаты.

Проблема возникает, когда сборщик мусора работает во время разветвления. Я исследовал три случая, которые иллюстрируют проблему.

Случай 1: Мы выделяем много объектов (строки длиной не более 20 байтов) в памяти с использованием массива. Строки создаются с использованием случайного числа и форматирования строк. Когда процесс вилки и мы вынуждаем GC работать в дочернем, вся разделяемая память становится частной, что приводит к дублированию исходной памяти.

Случай 2: Мы выделяем много объектов (строк) в памяти с помощью массива, но строка создается с помощью функции rand.to_s, поэтому мы удаляем форматирование данных по сравнению с предыдущим случаем. В итоге мы используем меньший объем используемой памяти, предположительно из-за меньшего количества мусора. Когда процесс вилки и мы вынуждаем GC работать в дочернем элементе, только часть памяти становится частной. У нас есть дублирование исходной памяти, но в меньшей степени.

Случай 3: Мы выделяем меньше объектов по сравнению с ранее, но объекты больше, так что объем выделенной памяти остается таким же, как и в предыдущих случаях. Когда процесс вилки и мы вынуждаем GC работать в ребёнке, вся память остается разделенной, т.е. Дублирование памяти.

Здесь я вставляю код Ruby, который использовался для этих экспериментов. Для переключения между случаями вам нужно только изменить значение "option" в функции memory_object. Код был протестирован с использованием Ruby 2.2.2, 2.2.1, 2.1.3, 2.1.5 и 1.9.3 на машине Ubuntu 14.04.

Пример вывода для случая 1:

ruby version 2.2.2 
 proces   pid log                   priv_dirty   shared_dirty 
 Parent  3897 post alloc                   38            0 
 Parent  3897 4 fork                        0           37 
 Child   3937 4 initial                     0           37 
 Child   3937 8 empty GC                   35            5 

Точный же код был написан на Python, и во всех случаях CoW работает отлично.

Пример вывода для случая 1:

python version 2.7.6 (default, Mar 22 2014, 22:59:56) 
[GCC 4.8.2] 
 proces   pid log                   priv_dirty shared_dirty 
 Parent  4308 post alloc                35             0 
 Parent  4308 4 fork                     0            35 
 Child   4309 4 initial                  0            35 
 Child   4309 10 empty GC                1            34 

Код Ruby

$start_time=Time.new

# Monitor use of Resident and Virtual memory.
class Memory

    shared_dirty = '.+?Shared_Dirty:\s+(\d+)'
    priv_dirty = '.+?Private_Dirty:\s+(\d+)'
    MEM_REGEXP = /#{shared_dirty}#{priv_dirty}/m

    # get memory usage
    def self.get_memory_map( pids)
        memory_map = {}
        memory_map[ :pids_found] = {}
        memory_map[ :shared_dirty] = 0
        memory_map[ :priv_dirty] = 0

        pids.each do |pid|
            begin
                lines = nil
                lines = File.read( "/proc/#{pid}/smaps")
            rescue
                lines = nil
            end
            if lines
                lines.scan(MEM_REGEXP) do |shared_dirty, priv_dirty|
                    memory_map[ :pids_found][pid] = true
                    memory_map[ :shared_dirty] += shared_dirty.to_i
                    memory_map[ :priv_dirty] += priv_dirty.to_i
                end
            end
        end
        memory_map[ :pids_found] = memory_map[ :pids_found].keys
        return memory_map
    end

    # get the processes and get the value of the memory usage
    def self.memory_usage( )
        pids   = [ $$]
        result = self.get_memory_map( pids)

        result[ :pids]   = pids
        return result
    end

    # print the values of the private and shared memories
    def self.log( process_name='', log_tag="")
        if process_name == "header"
            puts " %-6s %5s %-12s %10s %10s\n" % ["proces", "pid", "log", "priv_dirty", "shared_dirty"]
        else
            time = Time.new - $start_time
            mem = Memory.memory_usage( )
            puts " %-6s %5d %-12s %10d %10d\n" % [process_name, $$, log_tag, mem[:priv_dirty]/1000, mem[:shared_dirty]/1000]
        end
    end
end

# function to delay the processes a bit
def time_step( n)
    while Time.new - $start_time < n
        sleep( 0.01)
    end
end

# create an object of specified size. The option argument can be changed from 0 to 2 to visualize the behavior of the GC in various cases
#
# case 0 (default) : we make a huge array of small objects by formatting a string
# case 1 : we make a huge array of small objects without formatting a string (we use the to_s function)
# case 2 : we make a smaller array of big objects
def memory_object( size, option=1)
    result = []
    count = size/20

    if option > 3 or option < 1
        count.times do
            result << "%20.18f" % rand
        end
    elsif option == 1
        count.times do
            result << rand.to_s
        end
    elsif option == 2
        count = count/10
        count.times do
            result << ("%20.18f" % rand)*30
        end
    end

    return result
end

##### main #####

puts "ruby version #{RUBY_VERSION}"

GC.disable

# print the column headers and first line
Memory.log( "header")

# Allocation of memory
big_memory = memory_object( 1000 * 1000 * 10)

Memory.log( "Parent", "post alloc")

lab_time = Time.new - $start_time
if lab_time < 3.9
    lab_time = 0
end

# start the forking
pid = fork do
    time = 4
    time_step( time + lab_time)
    Memory.log( "Child", "#{time} initial")

    # force GC when nothing happened
    GC.enable; GC.start; GC.disable

    time = 8
    time_step( time + lab_time)
    Memory.log( "Child", "#{time} empty GC")

    sleep( 1)
    STDOUT.flush
    exit!
end

time = 4
time_step( time + lab_time)
Memory.log( "Parent", "#{time} fork")

# wait for the child to finish
Process.wait( pid)

Код Python

import re
import time
import os
import random
import sys
import gc

start_time=time.time()

# Monitor use of Resident and Virtual memory.
class Memory:   

    def __init__(self):
        self.shared_dirty = '.+?Shared_Dirty:\s+(\d+)'
        self.priv_dirty = '.+?Private_Dirty:\s+(\d+)'
        self.MEM_REGEXP = re.compile("{shared_dirty}{priv_dirty}".format(shared_dirty=self.shared_dirty, priv_dirty=self.priv_dirty), re.DOTALL)

    # get memory usage
    def get_memory_map(self, pids):
        memory_map = {}
        memory_map[ "pids_found" ] = {}
        memory_map[ "shared_dirty" ] = 0
        memory_map[ "priv_dirty" ] = 0

        for pid in pids:
            try:
                lines = None

                with open( "/proc/{pid}/smaps".format(pid=pid), "r" ) as infile:
                    lines = infile.read()
            except:
                lines = None

            if lines:
                for shared_dirty, priv_dirty in re.findall( self.MEM_REGEXP, lines ):
                    memory_map[ "pids_found" ][pid] = True
                    memory_map[ "shared_dirty" ] += int( shared_dirty )
                    memory_map[ "priv_dirty" ] += int( priv_dirty )     

        memory_map[ "pids_found" ] = memory_map[ "pids_found" ].keys()
        return memory_map

    # get the processes and get the value of the memory usage   
    def memory_usage( self):
        pids   = [ os.getpid() ]
        result = self.get_memory_map( pids)

        result[ "pids" ]   = pids

        return result

    # print the values of the private and shared memories
    def log( self, process_name='', log_tag=""):
        if process_name == "header":
            print " %-6s %5s %-12s %10s %10s" % ("proces", "pid", "log", "priv_dirty", "shared_dirty")
        else:
            global start_time
            Time = time.time() - start_time
            mem = self.memory_usage( )
            print " %-6s %5d %-12s %10d %10d" % (process_name, os.getpid(), log_tag, mem["priv_dirty"]/1000, mem["shared_dirty"]/1000)

# function to delay the processes a bit
def time_step( n):
    global start_time
    while (time.time() - start_time) < n:
        time.sleep( 0.01)

# create an object of specified size. The option argument can be changed from 0 to 2 to visualize the behavior of the GC in various cases
#
# case 0 (default) : we make a huge array of small objects by formatting a string
# case 1 : we make a huge array of small objects without formatting a string (we use the to_s function)
# case 2 : we make a smaller array of big objects                                       
def memory_object( size, option=2):
    count = size/20

    if option > 3 or option < 1:
        result = [ "%20.18f"% random.random() for i in xrange(count) ]

    elif option == 1:
        result = [ str( random.random() ) for i in xrange(count) ]

    elif option == 2:
        count = count/10
        result = [ ("%20.18f"% random.random())*30 for i in xrange(count) ]

    return result

##### main #####

print "python version {version}".format(version=sys.version)

memory = Memory()

gc.disable()

# print the column headers and first line
memory.log( "header")   # Print the headers of the columns

# Allocation of memory
big_memory = memory_object( 1000 * 1000 * 10)   # Allocate memory

memory.log( "Parent", "post alloc")

lab_time = time.time() - start_time
if lab_time < 3.9:
    lab_time = 0

# start the forking
pid = os.fork()     # fork the process
if pid == 0:
    Time = 4
    time_step( Time + lab_time)
    memory.log( "Child", "{time} initial".format(time=Time))

    # force GC when nothing happened
    gc.enable(); gc.collect(); gc.disable();

    Time = 10
    time_step( Time + lab_time)
    memory.log( "Child", "{time} empty GC".format(time=Time))

    time.sleep( 1)

    sys.exit(0)

Time = 4
time_step( Time + lab_time)
memory.log( "Parent", "{time} fork".format(time=Time))

# Wait for child process to finish
os.waitpid( pid, 0)

ИЗМЕНИТЬ

Действительно, вызывая GC несколько раз, прежде чем разветвлять процесс, решает проблему, и я очень удивлен. Я также запускаю код с помощью Ruby 2.0.0, и проблема даже не появляется, поэтому она должна быть связана с GC этого поколения, как вы упомянули. Однако, если я вызываю функцию memory_object без назначения вывода любым переменным (я только создаю мусор), тогда память дублируется. Объем памяти, который копируется, зависит от количества мусора, который я создаю - чем больше мусора, тем больше памяти становится приватным.

Любые идеи, как я могу это предотвратить?

Вот некоторые результаты

Запуск GC в 2.0.0

ruby version 2.0.0
 proces   pid log          priv_dirty shared_dirty
 Parent  3664 post alloc           67          0
 Parent  3664 4 fork                1         69
 Child   3700 4 initial             1         69
 Child   3700 8 empty GC            6         65

Вызов memory_object (1000 * 1000) в дочернем

ruby version 2.0.0
 proces   pid log          priv_dirty shared_dirty
 Parent  3703 post alloc           67          0
 Parent  3703 4 fork                1         70
 Child   3739 4 initial             1         70
 Child   3739 8 empty GC           15         56

Вызов memory_object (1000 * 1000 * 10)

ruby version 2.0.0
 proces   pid log          priv_dirty shared_dirty
 Parent  3743 post alloc           67          0
 Parent  3743 4 fork                1         69
 Child   3779 4 initial             1         69
 Child   3779 8 empty GC           89          5

Ответ 1

UPD2

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

Итак, я добавил функцию очистки для запуска GC каждые 2000 циклов (просто включение ленивого GC не помогло):

count.times do |i|
  cleanup(i)
  result << "%20.18f" % rand
end

#......snip........#

def cleanup(i)
      if ((i%2000).zero?)
        GC.enable; GC.start; GC.disable
      end
end   

##### main #####

В результате (с генерацией memory_object( 1000 * 1000 * 10) после fork):

RUBY_GC_HEAP_INIT_SLOTS=600000 ruby gc-test.rb 0
ruby version 2.2.0
 proces   pid log          priv_dirty shared_dirty
 Parent  2501 post alloc           35          0
 Parent  2501 4 fork                0         35
 Child   2503 4 initial             0         35
 Child   2503 8 empty GC           28         22

Да, это влияет на производительность, но только перед разветвлением, т.е. увеличивает время загрузки в вашем случае.


UPD1

Только что найденный критерий, с помощью которого ruby ​​2.2 устанавливает старые биты объекта, это 3 GC, поэтому, если вы добавите следующее перед форкировкой:

GC.enable; 3.times {GC.start}; GC.disable
# start the forking

вы получите (опция 1 в командной строке):

$ RUBY_GC_HEAP_INIT_SLOTS=600000 ruby gc-test.rb 1
ruby version 2.2.0
 proces   pid log          priv_dirty shared_dirty
 Parent  2368 post alloc           31          0
 Parent  2368 4 fork                1         34
 Child   2370 4 initial             1         34
 Child   2370 8 empty GC            2         32

Но это нужно еще раз проверить относительно поведения таких объектов в будущих GC, по крайней мере, после того, как 100 GC :old_objects останется постоянным, поэтому я полагаю, что он должен быть ОК

Журнал с GC.stat здесь


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

Первый ответ

Мое предложение в комментарии выше было неправильным, на самом деле растровые таблицы являются спасителем.

(option = 1)

ruby version 2.0.0
 proces   pid log          priv_dirty shared_dirty
 Parent 14807 post alloc           27          0
 Parent 14807 4 fork                0         27
 Child  14809 4 initial             0         27
 Child  14809 8 empty GC            6         25 # << almost everything stays shared <<

Также был проведен вручную и протестирован Ruby Enterprise Edition, он только наполовину лучше, чем худшие.

ruby version 1.8.7
 proces   pid log          priv_dirty shared_dirty
 Parent 15064 post alloc           86          0
 Parent 15064 4 fork                2         84
 Child  15065 4 initial             2         84
 Child  15065 8 empty GC           40         46

(я сделал script бегом строго 1 GC, увеличив RUBY_GC_HEAP_INIT_SLOTS до 600k)