Работа с памятью в R

Хранение объектов

Для мониторинга используемой памяти можно обращаться к выводу функции gc() (основной её функционал — сбор мусора, garbage collection).

gc(full = TRUE)
##           used (Mb) gc trigger (Mb) max used (Mb)
## Ncells  636223 34.0    1211135 64.7  1211135 64.7
## Vcells 1166455  8.9    8388608 64.0  1819344 13.9

В R есть некоторое различие в том, как хранятся и мониторятся разные объекты, поэтому в выводе gc() мы и видим две строки. Первая, Ncells, — это так называемые cons cells (пришедшие в R из Lisp и его особенностей хранения объектов в памяти). В R cons cells используются для хранения объектов, относящихся к самому языку: окружения, замыкания, парные списки, названия объектов и так далее. Полную статистику по этим объектам можно посмотреть с помощью функции memory.profile().

Вторая строчка вывода gc(), Vcells, — это, собственно, статистика используемых пользователем объектов. Правда, на данный момент это не выделяемая память (heaps), а блоки по 8 байт.

Группа used (Mb) отображает, сколько ячеек было использовано для хранения всех текущих объектов, а также объём задействованной памяти в мегабайтах. Группа колонок gc trigger (Mb) отражает, при каком количестве ячеек и памяти будет запущен сборщик мусора, группа max used (Mb) — статистики на момент предыдущего запуска сборщика.

При интерпретации вывода gc() в первую очередь надо смотреть на первую колонку (Mb), так как именно сумма значений обеих строк по этой колонке отражает состояние на текущий момент. Притом наибольший фокус должен быть, конечно, на строку Vcells: по сравнению с пользовательскими объектами объекты Ncells намного меньше, и их сложно увеличить до сколько-нибудь значимого объёма.

Использовать функцию gc() только для мониторинга памяти может быть несколько неудобно по ряду причин. В первую очередь, это всё же функция для вызова сборщика мусора, поэтому её выполнение может быть медленным в некоторых процедурах (в частности, не надо gc() использовать в циклах). Во-вторых, её данные могут быть несколько неточны, так как это оценка изнутри. Поэтому оценивать загруженность оперативной памяти лучше средствами операционной системы.

Размер объекта

При работе с большими объектами нередко возникает необходимость контролировать, какой объём оперативной памяти он занимает. Наиболее полезные инструменты здесь — функция object.size() базового пакета utils и функция obj_size() пакета lobstr. Обе функции отдают размер в байтах:

x <- rnorm(10e5)
object.size(x)
## 8000048 bytes
lobstr::obj_size(x)
## 8,000,048 B

Результат функции object.size() можно представить в нужном формате с помощью format() и аргумента units, который позволяет задать единицу измерения (“b”, “Kb”, “Mb”, “Gb” и проч.) и стандарт представления единицы измерения (standard с допустимыми значениями “legacy”, “IEC”, “SI” и “auto”). Стандарты legacy и IEC считают байты по степеням двойки, то есть 1024 байта равны 1 килобайту. Стандарт SI опирается на десятичную шкалу, в которой 1 килобайт равен 1000 байтам.

format(object.size(x), units = 'Mb', standard = 'legacy')
## [1] "7.6 Mb"
format(object.size(x), units = 'MiB', standard = 'IEC')
## [1] "7.6 MiB"
format(object.size(x), units = 'Mb', standard = 'SI')
## [1] "8 MB"

Оценки размеров объектов в памяти с помощью функции object.size() достаточно грубые, так как, во-первых, не учитывают ни окружение, которому принадлежит объект, ни название объекта, ни ситуации, когда части сложного объекта делятся с другими объектами (характерно для списков и таблиц, подробнее ниже). Во-вторых, размеры объектов могут несколько различаться на 64- и 32-битных системах.

Лимиты памяти

R хранит объекты в оперативной памяти. Как следствие, это накладывает некоторые ограничения на размер объектов в зависимости от операционной системы и её архитектуры, а также архитектуры приложения. Стоит отметить, что использование 32-битных версий R на 64-битных ОС также имеет свои особенности (в частности, на Windows), однако это достаточно редкое сочетание, и мы его не будем рассматривать.

Пользователи чаще всего сталкиваются с таким сообщением об ошибке: cannot allocate vector of size XXX (не могу разместить вектор размером XXX). Размер, который упоминается в этой ошибке, — это необходимое количество памяти сверх уже выделенного (а не сколько всего требуется памяти). В некоторых случаях это ошибка возникает из-за того, что операционная система не может выделить столько памяти под процесс (например, из-за высокой фрагментированности свободной памяти). В основном же эта ошибка возникает, когда размер объекта превышает \(2^{31} - 1\) байтов по какому-либо измерению (длина вектора, количество элементов в матрице, длина колонки и проч).

Для *nix-систем количество памяти, выделяемое под процесс, зависит от разрядности системы: для 32-битных систем под процесс выделяется 3 Gb (если не учитывать резервируемое под ядро), а в 64-битной системе уже намного больше — 128 Tb.

В Windows ситуация несколько сложнее. Во-первых, в 32-битных системах под процесс выделяется 2 Gb (при определенных настройках — 3 Gb). В 64-битных системах больше, но с ограничением в 8 Tb. Из-за того, что Windows ограничивает размер выделяемой памяти под процесс, в R есть инструменты управлениями этими лимитами — функции memory.size() и memory.limit(). Первая возвращает текущий или максимальный лимит памяти. Вторая возвращает текущий лимит или же увеличивает лимит памяти. Обе функции могут быть использованы только в Windows, в *nix-системах они будут отдавать Inf.

Понимать, какие есть лимиты памяти в операционной системе и в приложении, полезно, но рядовой пользователь с ними редко сталкивается. Скорее, возникновение ошибки cannot allocate vector of size XXX свидетельствует либо о плохо или с ошибкой написанном коде (что чаще), либо же о плохо построенном процессе работы с данными.

Трассировка объектов

Для отслеживания адреса объекта в памяти и его изменения при различных операциях используется функция tracemem():

x <- 1:5
tracemem(x)
## [1] "<0x561be4411450>"

При изменении элемента объекта адрес меняется и изменение адресов отображается сразу, без повторного вызова tracemem():

x[5] <- 6L
## tracemem[0x561be4411450 -> 0x561be817c1c8]: eval eval withVisible withCallingHandlers handle timing_fn evaluate_call <Anonymous> evaluate in_dir eng_r block_exec call_block process_group.block process_group withCallingHandlers process_file <Anonymous> <Anonymous> do.call eval eval eval eval eval.parent local

Для отключения трассировки можно воспользоваться функцией untracemem(), только для начала сохраним актуальный адрес объекта:

# сохраняем 
prev_address <- tracemem(x)

# отключаем трассировку и изменяем
untracemem(x)
x[3] <- 99

При желании после отключения трассировки можно сделать какую-нибудь новую операцию (или несколько) и c помощью retracemem() отследить изменение адреса с последнего известного до текущего:

# добавляем ещё одно изменение
x <- 9

# смотрим изменение адреса с предыдущего известного
retracemem(x, previous = prev_address)
## tracemem[<0x561be817c1c8> -> 0x561be7263ea8]: eval eval withVisible withCallingHandlers handle timing_fn evaluate_call <Anonymous> evaluate in_dir eng_r block_exec call_block process_group.block process_group withCallingHandlers process_file <Anonymous> <Anonymous> do.call eval eval eval eval eval.parent local

Функция tracemem(), использует вывод C-функции duplicate(), которая вызывается при копировании объектов. Как следствие, tracemem() бессмысленна или неприменима к функциям, окружениям и прочим объектам, которые нельзя дуплицировать.

Помимо tracemem() для получения адреса объекта можно использовать более информативный вызов внутренней функции inspect(), которая также возвращает адрес в памяти и C-тип данных, а также функцию lobstr::obj_addr(), которая является оболочкой над inspect():

## [1] "<0x561be7263ea8>"
.Internal(inspect(x))
## @561be7263ea8 14 REALSXP g0c1 [REF(3),TR] (len=1, tl=0) 9
lobstr::obj_addr(x)
## [1] "0x561be7263ea8"

Хранение разных типов данных

Для лучшего понимания основных типов данных в R рассмотрим их с точки зрения хранения в памяти. Это можно сделать как с помощью tracemem(), так и с помощью функций ref(), obj_addr() и obj_addrs() пакета lobstr, которые возвращают более детальную информацию об объекте и его элементах.

Векторы

Векторы элементов атомарных типов, за исключением строкового, имеют только адрес начала вектора. Со строковыми значениями ситуация несколько иная: в R используется так называемый глобальный кэш строковых значений (global string pooling). Это значит, что каждое новое строковое значение получает свой адрес и попадает в кэш, все же дальнейшие обращения к этой строке используют закэшированное значение (то есть обращаются по тому же адресу). Таким образом, вектор строковых значений будет содержать значения из кэша и какое-то количество ссылок на эти значения, которых может быть больше, чем уникальных строковых значений:

x_char <- sample(c('ab', 'ac', 'cb'), 7, replace = TRUE)
x_char <- sort(x_char)
print(x_char)
## [1] "ab" "ac" "ac" "ac" "cb" "cb" "cb"
lobstr::ref(x_char, character = TRUE)
## █ [1:0x561be66ea228] <chr> 
## ├─[2:0x561be68c0458] <string: "ab"> 
## ├─[3:0x561be68c0500] <string: "ac"> 
## ├─[3:0x561be68c0500] 
## ├─[3:0x561be68c0500] 
## ├─[4:0x561be27fbf98] <string: "cb"> 
## ├─[4:0x561be27fbf98] 
## └─[4:0x561be27fbf98]
lobstr::obj_addrs(x_char)
## [1] "0x561be68c0458" "0x561be68c0500" "0x561be68c0500" "0x561be68c0500"
## [5] "0x561be27fbf98" "0x561be27fbf98" "0x561be27fbf98"

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

Списки и таблицы

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

# создадим список и посмотрим его адрес и адреса его подсписков
my_list <- list(e1 = 1:3, e2 = letters[1:5], e3 = month.abb[1])
lobstr::ref(my_list)
## █ [1:0x561be759a708] <named list> 
## ├─e1 = [2:0x561be61d8a20] <int> 
## ├─e2 = [3:0x561be7fd24f8] <chr> 
## └─e3 = [4:0x561be6321ad8] <chr>
# создаём новый подсписок
my_list$e4 <- sample(5)
lobstr::ref(my_list)
## █ [1:0x561be7e33108] <named list> 
## ├─e1 = [2:0x561be61d8a20] <int> 
## ├─e2 = [3:0x561be7fd24f8] <chr> 
## ├─e3 = [4:0x561be6321ad8] <chr> 
## └─e4 = [5:0x561be7e331f8] <int>

Аналогично с таблицами:

# создаём таблицу и смотрим её адреса
my_df <- data.frame(col1 = 1:5, col2 = letters[1:5], col3 = month.abb[1:5], stringsAsFactors = FALSE)
lobstr::ref(my_df)
## █ [1:0x561be7fe6f18] <df[,3]> 
## ├─col1 = [2:0x561be5dc3f20] <int> 
## ├─col2 = [3:0x561be809a998] <chr> 
## └─col3 = [4:0x561be809a928] <chr>
# создаём новую колонку и смотрим изменения
my_df$col4 <- sample(5)
lobstr::ref(my_df)
## █ [1:0x561be57f2588] <df[,4]> 
## ├─col1 = [2:0x561be5dc3f20] <int> 
## ├─col2 = [3:0x561be809a998] <chr> 
## ├─col3 = [4:0x561be809a928] <chr> 
## └─col4 = [5:0x561be57f26c8] <int>

Подходы к копированию и изменению объектов

Copy-on-modify

В R реализован вызов по значению (call-by-value). Правда, в несколько нетрадиционном виде: его корректнее было бы назвать вызовом по соиспользованию (call-by-sharing), в R Internals встречается формулировка call by value illusion. Смысл заключается в следующем: когда мы создаём копию объекта, то первоначально адреса исходного объекта и копии совпадают:

# создаём объект
x <- 1:5

# создаём его копию
y <- x

# смотрим адреса обоих объектов
tracemem(x)
## [1] "<0x561be52e0a00>"
## [1] "<0x561be52e0a00>"

То есть до какого-то момента объект и его копия используют один и тот же адрес в памяти. По-настоящему копия создаётся в тот момент, когда над копией производится какое-то действие:

y[6] <- 99L
## tracemem[0x561be52e0a00 -> 0x561be74d9da8]: eval eval withVisible withCallingHandlers handle timing_fn evaluate_call <Anonymous> evaluate in_dir eng_r block_exec call_block process_group.block process_group withCallingHandlers process_file <Anonymous> <Anonymous> do.call eval eval eval eval eval.parent local

Одновременно с механизмом copy-on-modify проявляется и обратная сторона процесса: изменение объекта всегда сопровождается его копированием, в некоторых случаях изменение может быть даже скрыто от пользователя. Например, когда мы хотим добавить к вектору целых значений numeric-значение.

x <- 1:5
class(x)
## [1] "integer"
## [1] "<0x561be733eae0>"
x[6] <- 6
## tracemem[0x561be733eae0 -> 0x561be7156318]: eval eval withVisible withCallingHandlers handle timing_fn evaluate_call <Anonymous> evaluate in_dir eng_r block_exec call_block process_group.block process_group withCallingHandlers process_file <Anonymous> <Anonymous> do.call eval eval eval eval eval.parent local 
## tracemem[0x561be7156318 -> 0x561be756c4c8]: eval eval withVisible withCallingHandlers handle timing_fn evaluate_call <Anonymous> evaluate in_dir eng_r block_exec call_block process_group.block process_group withCallingHandlers process_file <Anonymous> <Anonymous> do.call eval eval eval eval eval.parent local
## [1] "numeric"

Как мы видим, сначала за счёт неявного преобразования типов (integer в numeric) происходит копирование и изменение объекта, а потом — происходит добавление нового элемента, также с копированием и созданием объекта новой длины.

Shallow copy

Полное копирование объекта при его изменении (deep copy) — это достаточно дорогостоящая процедура, особенно при больших объемах данных, так как фактически требует двух- или даже трёхкратного объёма памяти объекта для проведения минимальных операций. В частности, из-за такого механизма R долгое время считался очень медленным языком. Тем не менее, в версии 3.1.0 был введён новый механизм копирования — shallow copy, поверхностное копирование. В первую очередь это касается списков и таблиц.

Основная идея механизма — копировать только тот элемент (подсписок или колонку), которая реально изменяется. Остальные элементы оставлять неизменными до тех пор, пока не возникнет какая-то необходимость в их изменении. По сути, механизм shallow copy — сочетание особенности хранения списков в памяти и механизма copy-on-modify.

# создаём таблицу и смотрим её адреса
my_df <- data.frame(col1 = 1:5, col2 = letters[1:5], col3 = month.abb[1:5], stringsAsFactors = FALSE)
lobstr::ref(my_df)
## █ [1:0x561be7fdde68] <df[,3]> 
## ├─col1 = [2:0x561be6ac3310] <int> 
## ├─col2 = [3:0x561be551a3f8] <chr> 
## └─col3 = [4:0x561be56ff338] <chr>
# создаём копию таблицы
my_df_copy <- my_df
lobstr::ref(my_df_copy)
## █ [1:0x561be7fdde68] <df[,3]> 
## ├─col1 = [2:0x561be6ac3310] <int> 
## ├─col2 = [3:0x561be551a3f8] <chr> 
## └─col3 = [4:0x561be56ff338] <chr>
# создаём новую колонку и смотрим изменения
my_df_copy$col1 <- my_df_copy$col1 * 2
my_df_copy$col4 <- sample(5)
lobstr::ref(my_df_copy)
## █ [1:0x561be80abfa8] <df[,4]> 
## ├─col1 = [2:0x561be704b568] <dbl> 
## ├─col2 = [3:0x561be551a3f8] <chr> 
## ├─col3 = [4:0x561be56ff338] <chr> 
## └─col4 = [5:0x561be80ac098] <int>

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

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

# смотрим адреса таблицы и колонок
lobstr::ref(my_df_copy)
## █ [1:0x561be80abfa8] <df[,4]> 
## ├─col1 = [2:0x561be704b568] <dbl> 
## ├─col2 = [3:0x561be551a3f8] <chr> 
## ├─col3 = [4:0x561be56ff338] <chr> 
## └─col4 = [5:0x561be80ac098] <int>
# переприсваиваем в строку её же значение (формально ничего не меняется)
my_df_copy[2, ] <- my_df_copy[2, ]

# смотрим ещё раз адреса
lobstr::ref(my_df_copy)
## █ [1:0x561be7e57fd8] <df[,4]> 
## ├─col1 = [2:0x561be7fbd9d8] <dbl> 
## ├─col2 = [3:0x561be7fbd888] <chr> 
## ├─col3 = [4:0x561be7fbd738] <chr> 
## └─col4 = [5:0x561be7e57da8] <int>

Полное обновление таблицы при добавлении строки — одна из причин, почему неэффективны циклы, использующие rbind() в теле цикла.

Modify-in-place

Помимо copy-on-modify, также есть возможность модификации объектов без копирования, на месте, modify-in-place. Подобные модификации возможны для векторов, а также для окружений — при изменении объекта внутри окружения адрес самого окружения не меняется:

# создаём новое окружение и один объект в нём
my_env <- new.env()
my_env$a <- 1:5
lobstr::ref(my_env)
## █ [1:0x561be6007210] <env> 
## └─a = [2:0x561be5fb87b8] <int>
# меняем объект
my_env$a[6] <- 99
lobstr::ref(my_env)
## █ [1:0x561be6007210] <env> 
## └─a = [2:0x561be80cd458] <dbl>

На практике лучше всего (да и полезней) изменение на месте без копирования реализовано в пакете data.table. При изменении колонки вся таблица не копируется, а меняется только колонка или даже её часть.

library(data.table)
my_dt <- data.table(col1 = 1:5, col2 = letters[1:5], col3 = month.abb[1:5])
lobstr::ref(my_dt)
## █ [1:0x561be7e622e0] <data.table[,3]> 
## ├─col1 = [2:0x561be720e558] <int> 
## ├─col2 = [3:0x561be756c618] <chr> 
## └─col3 = [4:0x561be756cdf8] <chr>
my_dt[, col1 := col1 * 2]
lobstr::ref(my_dt)
## █ [1:0x561be7e622e0] <data.table[,3]> 
## ├─col1 = [2:0x561be7fbcf58] <dbl> 
## ├─col2 = [3:0x561be756c618] <chr> 
## └─col3 = [4:0x561be756cdf8] <chr>

Однако к data.table также применима логика copy-on-modify, что вызывает казусы: когда стандартным путём создаётся и изменяется копия таблицы, изменения также проявляются и в исходной таблице:

# создаём таблицу и её копию
my_dt <- data.table(col1 = 1:5, col2 = letters[1:5], col3 = month.abb[1:5])
my_dt_copy <- my_dt

# смотрим их адреса
lobstr::ref(my_dt)
## █ [1:0x561be7489b50] <data.table[,3]> 
## ├─col1 = [2:0x561be7ee2918] <int> 
## ├─col2 = [3:0x561be5801eb8] <chr> 
## └─col3 = [4:0x561be5801d68] <chr>
lobstr::ref(my_dt_copy)
## █ [1:0x561be7489b50] <data.table[,3]> 
## ├─col1 = [2:0x561be7ee2918] <int> 
## ├─col2 = [3:0x561be5801eb8] <chr> 
## └─col3 = [4:0x561be5801d68] <chr>
# изменяем колонку в копии таблицы
my_dt_copy[, col1 := col1 * 2]

# смотрим результат
my_dt_copy
##    col1 col2 col3
## 1:    2    a  Jan
## 2:    4    b  Feb
## 3:    6    c  Mar
## 4:    8    d  Apr
## 5:   10    e  May
my_dt
##    col1 col2 col3
## 1:    2    a  Jan
## 2:    4    b  Feb
## 3:    6    c  Mar
## 4:    8    d  Apr
## 5:   10    e  May
# проверяем на тождество
all.equal(my_dt, my_dt_copy)
## [1] TRUE
# ещё раз смотрим адреса
lobstr::ref(my_dt)
## █ [1:0x561be7489b50] <data.table[,3]> 
## ├─col1 = [2:0x561be74804a8] <dbl> 
## ├─col2 = [3:0x561be5801eb8] <chr> 
## └─col3 = [4:0x561be5801d68] <chr>
lobstr::ref(my_dt_copy)
## █ [1:0x561be7489b50] <data.table[,3]> 
## ├─col1 = [2:0x561be74804a8] <dbl> 
## ├─col2 = [3:0x561be5801eb8] <chr> 
## └─col3 = [4:0x561be5801d68] <chr>

Для того чтобы избегать подобных ситуаций, следует пользоваться явной функцией копирования data-table объектов — data.table::copy().

Сборщик мусора

Сборщик мусора в R использует классический алгоритм — подсчёт ссылок на объекты в памяти. То есть подсчитывается, сколько ссылок есть на объект при каждом изменении, и если количество ссылок равно нулю, то объект может быть удалён, а занимаемая им память — возвращена операционной системе.

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

Сборщик мусора в R работает в автоматическом режиме, однако в некоторых случаях есть смысл форсировать процесс и вызвать сборщик вручную. Основная функция вызова сборщика мусора — уже описанная ранее функция gc(). Аргумент full = TRUE позволяет игнорировать поколения объектов, в результате сборщик мусора просматривает все объекты.

Также есть функция gcinfo(), если её запустить с аргументом verbose = TRUE, то она присутствует в фоновом режиме во время сессии и при автоматическом срабатывании сборщика мусора сообщает пользователю текущие статистики Ncells/Vcells. Функции gctorture() и gctorture2() используются в очень редких случаях (и в основном разработчиками R), так как форсируют сборщик мусора при каждом размещении объекта в памяти, что существенно замедляет работу.

Помимо сборщика мусора, в R есть возможность вручную запускать финализатор во время выполнения какой-либо операции или же при завершении сессии. Тем не менее, функция reg.finalizer() используется достаточно редко, и её применяют обычно только к R6-классам.