Работа с памятью в 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
untracemem(x)
Функция tracemem()
, использует вывод C-функции duplicate()
, которая вызывается при копировании объектов. Как следствие, tracemem()
бессмысленна или неприменима к функциям, окружениям и прочим объектам, которые нельзя дуплицировать.
Помимо tracemem()
для получения адреса объекта можно использовать более информативный вызов внутренней функции inspect()
, которая также возвращает адрес в памяти и C-тип данных, а также функцию lobstr::obj_addr()
, которая является оболочкой над inspect()
:
tracemem(x)
## [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). Это значит, что каждое новое строковое значение получает свой адрес и попадает в кэш, все же дальнейшие обращения к этой строке используют закэшированное значение (то есть обращаются по тому же адресу). Таким образом, вектор строковых значений будет содержать значения из кэша и какое-то количество ссылок на эти значения, которых может быть больше, чем уникальных строковых значений:
## [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>
## █ [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>
## █ [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>"
tracemem(y)
## [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
untracemem(x)
untracemem(y)
Одновременно с механизмом copy-on-modify
проявляется и обратная сторона процесса: изменение объекта всегда сопровождается его копированием, в некоторых случаях изменение может быть даже скрыто от пользователя. Например, когда мы хотим добавить к вектору целых значений numeric-значение.
x <- 1:5
class(x)
## [1] "integer"
tracemem(x)
## [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
class(x)
## [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-классам.