Как стать автором
Обновить

Несколько штрихов о работе с идентификаторами bigint в R

Время на прочтение6 мин
Количество просмотров1.2K

Каждый раз, когда начинается разговор об использовании различных БД в качестве источника данных, появляется тема идентификаторов записей, объектов или чего-либо иного. Иногда согласование протокола обмена может рассматриваться участниками несколько месяцев. 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, которые в ежедневной работе мало кто использует. Если есть существенные дополнения, замечания или правки, будет весьма интересно с ними познакомиться.


Предыдущая публикация — «Применение R для утилитарных задач».

Теги:
Хабы:
Всего голосов 6: ↑6 и ↓0+6
Комментарии1

Публикации

Истории

Работа

Data Scientist
95 вакансий

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн
10 – 11 октября
HR IT & Team Lead конференция «Битва за IT-таланты»
МоскваОнлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн