Каждый раз, когда начинается разговор об использовании различных БД в качестве источника данных, появляется тема идентификаторов записей, объектов или чего-либо иного. Иногда согласование протокола обмена может рассматриваться участниками несколько месяцев. int
-bigint
-guid
, далее по кругу. Для объемных задач, с учетом того, что нативно в R нет поддержки bigint
(емкость ~2^64) выбор правильного представления таких идентификаторов может оказаться критичным в части производительности. Есть ли очевидное и универсальное обходное решение? Ниже несколько практических соображений, которые могут применяться в проектах в качестве лакмусовой бумажки.
Как правило, идентификаторы будут использоваться для трех классов задач:
- группировка;
- фильтрация;
- объединение.
Исходя из этого и оценим различные подходы.
Является продолжением предыдущих публикаций.
Храним как string
Вариант для небольших данных вполне себе неплохой. Тут и возможность подхватывать любую длину идентификатора, и возможность поддерживать не только числовые, но и цифро-буквенные идентификаторы. Дополнительным преимуществом являтся гарантированная возможность корректного получения данных через любой ненативный протокол БД, например, через REST API шлюза.
Минусы тоже очевидны. Большой расход памяти, увеличение информационного объема с БД, деградация производительности как на сетевом уровне, так и на вычислительном уровне.
Используем пакет bit64
Многие, кто слышал только название этого пакета, могут подумать, что вот оно, идеальное решение. Увы, это не совсем так. Мало того, что это надстройка поверх numeric
(цитата: 'Again the choice is obvious: R has only one 64 bit data type: doubles. By using doubles,
integer64 inherits some functionality such as is.atomic, length, length<-, names, names<-, dim, dim<-, dimnames, dimnames.'), так еще идет массовое расширение базовой арифметики и нет гарантий, что нигде не рванет и не будет конфликта с другими пакетами.
Используем тип numeric
Вполне корректный трюк, являющийся хорошим компромиссом для тех, кто знает, что именно будет скрываться в int64
ответе от БД. Ведь не всегда там действительно будут задействованы все 64 бита. Часто там может быть число сильно меньше, чем 2^64.
Такое решение возможно в силу специфики формата хранения чисел с плавающей точкой двойной точности. Детали можно прочитать в популярной статье Double-precision floating-point format.
The 53-bit significand precision gives from 15 to 17 significant decimal digits precision (2?53 ? 1.11 ? 10?16). If a decimal string with at most 15 significant digits is converted to IEEE 754 double-precision representation, and then converted back to a decimal string with the same number of digits, the final result should match the original string. If an IEEE 754 double-precision number is converted to a decimal string with at least 17 significant digits, and then converted back to double-precision representation, the final result must match the original number.
Если у вас в идентификаторе будет 15 или меньше десятичных цифр, то вполне можно использовать numeric
и не беспокоиться.
Этот же трюк хорош, когда надо работать со временнЫми данными, особенно, содержащих миллисекунды. Передача по сети временнЫх данных в текстовом виде требует времени, вдобавок на принимающей стороне надо запускать парсер текст -> POSIXct
, что тоже крайне ресурсоемко (просадка по производительности в разы). Передавать в бинарном виде — не факт, что все драйверы поддержат передачу временной зоны и миллисекунд. А вот передача времени с точностью до миллисекунд в UTC зоне в представлении unix timestamp (13 десятичных знаков) очень хорошо и без потерь обеспечивается форматом numeric
.
Не все так просто и очевидно
Если же взглянуть на вариант со строками более пристально, то очевидность и категоричность первоначального утверждения немного отступают. Работа со строками в R построена не совсем прямолинейно, даже опуская нюансы по выравниванию блоков памяти и упреждающей выборке. Судя по книгам и углубленной документации, строковые переменные хранятся не сами по себе в переменной, а помещаются в глобальный строковой пул (global string pool). Все строки. И этот пул используется строковыми массивами для снижения потребляемой памяти. Т.е. текстовый вектор будет представлять собой набор строк в глобальном пуле + вектор ссылок на записи из этого пул.
library(tidyverse)
library(magrittr)
library(stringi)
library(gmp)
library(profvis)
library(pryr)
library(rTRNG)
set.seed(46572)
RcppParallel::setThreadOptions(numThreads = parallel::detectCores() - 1)
# поставим большой штраф за переход к научному формату отображения чисел с плавающей точкой
options(scipen = 10000)
options(digits = 14)
options(pillar.sigfig = 14)
pryr::mem_used()
fname <- here::here("output", "dump.csv")
# соотношение 10^4, на размер объекта влияет длина строк (сумма указателей фиксированной длины + строковый пул)
m1 <- sample(stri_rand_strings(100, 64, "[a0-9]"), 10^7, replace = TRUE)
# теперь сбрасываем в файл
readr::write_csv(enframe(m1, name = NULL), fname)
# и читаем как независимый строковый источник
m2 <- readr::read_csv(fname, col_types = "c") %>% pull(value)
pryr::object_size(m2)
pryr::mem_used()
# посмотрим на объем этого файла
print(glue::glue("File size: {fs::file_size(fname)}. ",
"Constructed from file object's (m2) size: {fs::fs_bytes(pryr::object_size(m2))}. ",
"Pure pointer's size: {fs::fs_bytes(8*length(m2))}"))
.Internal(inspect(m1))
.Internal(inspect(m2))
Видим, что даже без ухода на уровень C++, гипотеза не так уж далека от истины. Объем строкового вектора почти совпадает с объемом 64-х битных указателей, а сама переменная занимает существенно меньший объем, чем файл на диске.
File size: 62M. Constructed from file object's (m2) size: 7.65M. Pure pointer's size: 7.63M
И содержание векторов до записи и после чтения идентично — соотв. элементы векторов ссылаются на одни и те же блоки памяти.
Так что при более пристальном изучении вопроса использование текстовых строк в качестве идентификаторов уже не кажется такой безумной идеей. Бенчмарки на группировки, фильтрацию и слияния, что средствами dplyr
, что средствами data.table
дают примерно похожие показания для numeric
и character
идентификаторов, что дает дополнительное подтверждение оптимизации за счет глобального пула. Идет ведь работа с указателями, размер которых равен либо 32, либо 64 бита в зависимости от сборки R (32/64), а это как раз и есть numeric
тип.
# перемножение с группировкой
gc()
pryr::mem_used()
bench::mark(
string = id_df %>% group_by(id_string) %>% summarise(j = prod(j, na.rm = TRUE)),
# bit64 = id_df %>% group_by(id_bit64) %>% summarise(j = prod(j, na.rm = TRUE)),
numeric = id_df %>% group_by(id_numeric) %>% summarise(j = prod(j, na.rm = TRUE)),
# gmp = id_df %>% group_by(id_gmp) %>% summarise(j = prod(j, na.rm = TRUE)),
check = FALSE
)
# фильтрация по идентификаторам
gc()
pryr::mem_used()
string_subset <- sample(unique(id_df$id_string), 20)
numeric_subset <- sample(unique(id_df$id_numeric), 20)
bench::mark(
string = dplyr::filter(id_df, id_string %in% string_subset),
numeric = dplyr::filter(id_df, id_numeric %in% numeric_subset),
check = FALSE
)
# слияние по идентификатору
gc()
pryr::mem_used()
# для честной оценки операций сделаем дубликат объектов
string_copy_df <- rlang::duplicate(dplyr::count(id_df, id_string))
numeric_copy_df <- rlang::duplicate(dplyr::count(id_df, id_numeric))
bench::mark(
string = id_df %>% dplyr::left_join(string_copy_df, by = "id_string"),
numeric = id_df %>% dplyr::left_join(numeric_copy_df, by = "id_numeric"),
iterations = 10,
check = FALSE
)
Кстати, максимальный размер доступной R памяти можно посмотреть командой fs::fs_bytes(memory.limit())
.
Для честности, следует отметить, что в dplyr
не всегда была быстрая работа со строками, см. кейс "Joining by a character column is slow, compared to joining by a factor column. #1386 {Closed}". В этом треде как раз и предлагается использовать возможности глобального пула строк и сравнивать не строки, как таковые, а указатели на строки.
Детали по управление памятью
Базовые источники
- R Internals by R Core Team, в частности Chapter 1 — R Internal Structures
- R internals. 1.1 SEXPs
- R internals. 12.1 Long vectors
- Advanced R. Memory. Оно же во второй редакции книги 2. Names and values
Заключение
Естественно, что этим вопросом задаются постоянно в том или ином виде, ряд ссылок ниже.
- In R is it better to use integer64, numeric, or character for large integer id numbers?
- R in a 64 bit world
- bit64: A S3 Class for Vectors of 64bit Integers
- REALLY LARGE NUMBERS IN R
- gmp: Multiple Precision Arithmetic
- Rmpfr: R MPFR — Multiple Precision Floating-Point Reliable
Но для того, чтобы осознанно понять, как правильно поступить, какие есть возможности и ограничения, лучше всего опуститься на максимально низкий уровень. Как оказывается, появляется масса не очевидной специфики. Публикация имеет исследовательский характер, поскольку затрагивает достаточно базовые аспекты R, которые в ежедневной работе мало кто использует. Если есть существенные дополнения, замечания или правки, будет весьма интересно с ними познакомиться.
Предыдущая публикация — «Применение R для утилитарных задач».