Быстрая конкатенация столбцов data.table

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

У меня есть скрытое подозрение, что способ, которым я пользуюсь вызовом eval(parse(...)), можно заменить чем-то более элегантным, но метод, приведенный ниже, является самым быстрым, что я смог получить до сих пор.

С 10 миллионами строк это занимает около 21,7 секунды по этим данным образца (база R paste0 занимает немного больше - 23,6 секунды). Мои фактические данные объединяют 18-20 столбцов и до 100 миллионов строк, поэтому замедление становится немного более непрактичным.

Любые идеи, чтобы получить это ускорилось?


Текущие методы

library(data.table)
library(stringi)

RowCount <- 1e7
DT <- data.table(x = "foo",
                 y = "bar",
                 a = sample.int(9, RowCount, TRUE),
                 b = sample.int(9, RowCount, TRUE),
                 c = sample.int(9, RowCount, TRUE),
                 d = sample.int(9, RowCount, TRUE),
                 e = sample.int(9, RowCount, TRUE),
                 f = sample.int(9, RowCount, TRUE))

## Generate an expression to paste an arbitrary list of columns together
ConcatCols <- c("x","a","b","c","d","e","f","y")
PasteStatement <- stri_c('stri_c(',stri_c(ConcatCols,collapse = ","),')')
print(PasteStatement)

дает

[1] "stri_c(x,a,b,c,d,e,f,y)"

который затем используется для конкатенации столбцов со следующим выражением:

DT[,State := eval(parse(text = PasteStatement))]

Пример вывода:

     x   y a b c d e f        State
1: foo bar 4 8 3 6 9 2 foo483692bar
2: foo bar 8 4 8 7 8 4 foo848784bar
3: foo bar 2 6 2 4 3 5 foo262435bar
4: foo bar 2 4 2 4 9 9 foo242499bar
5: foo bar 5 9 8 7 2 7 foo598727bar

Результаты профилирования

График пламени Данные


Обновление 1: fread, fwrite и sed

Следуя предложению @Gregor, попробовав использовать sed для выполнения конкатенации на диске. Благодаря функции data.table с быстрыми функциями fread и fwrite я смог записать столбцы на диск, устранить разделители запятой, используя sed, а затем прочитать обратно в пост-обработанном выходе примерно за 18,3 секунды - не достаточно быстро, чтобы сделать переключатель, но тем не менее интересным касанием!

ConcatCols <- c("x","a","b","c","d","e","f","y")
fwrite(DT[,..ConcatCols],"/home/xxx/DT.csv")
system("sed 's/,//g' /home/xxx/DT.csv > /home/xxx/DT_Post.csv ")
Post <- fread("/home/xxx/DT_Post.csv")
DT[,State := Post[[1]]]

Разбивка 18,3 общих секунд (неспособная использовать profvis с sed невидима для профайлера R)

  • data.table::fwrite() - 0,5 секунды
  • sed - 14.8 секунд
  • data.table::fread() - 3,0 секунды
  • := - 0.0 секунд

Если ничего другого, это свидетельствует об обширной работе авторов data.table по оптимизации производительности для IO диска. (Я использую версию разработки 1.10.5, которая добавляет многопоточность к fread, fwrite была многопоточной в течение некоторого времени).

Оговорка:, если есть способ обхода файла с помощью fwrite и пустого разделителя, как предложено @Gregor в другом комментарии ниже, тогда этот метод можно было бы правдоподобно сократить до ~ 3,5 секунды!

Обновление по этой касательной: forked data.table и прокомментировала строку, требующую разделителя больше длины 0, загадочно получилось несколько пробелов? Вызвав несколько секретов, пытающихся запутаться с внутренними элементами C, я помещал это на лед на время. Идеальное решение не требует записи на диск и будет хранить все в памяти.


Обновление 2: sprintf для целых конкретных случаев

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

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

Используя базовую функцию R sprintf и давая ей знать, что все входы будут целыми числами, мы можем сэкономить около 30% времени выполнения для 5 миллионов строк с 18 целыми столбцами для вычисления. (20,3 секунды вместо 28,9)

library(data.table)
library(stringi)
RowCount <- 5e6
DT <- data.table(x = "foo",
                 y = "bar",
                 a = sample.int(9, RowCount, TRUE),
                 b = sample.int(9, RowCount, TRUE),
                 c = sample.int(9, RowCount, TRUE),
                 d = sample.int(9, RowCount, TRUE),
                 e = sample.int(9, RowCount, TRUE),
                 f = sample.int(9, RowCount, TRUE))

## Generate an expression to paste an arbitrary list of columns together
ConcatCols <- list("a","b","c","d","e","f")
## Do it 3x as many times
ConcatCols <- c(ConcatCols,ConcatCols,ConcatCols)

## Using stringi::stri_c ---------------------------------------------------
stri_joinStatement <- stri_c('stri_join(',stri_c(ConcatCols,collapse = ","),', sep="", collapse=NULL, ignore_null=TRUE)')
DT[, State := eval(parse(text = stri_joinStatement))]

## Using sprintf -----------------------------------------------------------
sprintfStatement <- stri_c("sprintf('",stri_flatten(rep("%i",length(ConcatCols))),"', ",stri_c(ConcatCols,collapse = ","),")")
DT[,State_sprintf_i := eval(parse(text = sprintfStatement))]

Сгенерированные операторы выглядят следующим образом:

> cat(stri_joinStatement)
stri_join(a,b,c,d,e,f,a,b,c,d,e,f,a,b,c,d,e,f, sep="", collapse=NULL, ignore_null=TRUE)
> cat(sprintfStatement)
sprintf('%i%i%i%i%i%i%i%i%i%i%i%i%i%i%i%i%i%i', a,b,c,d,e,f,a,b,c,d,e,f,a,b,c,d,e,f)

sprintf


Обновление 3: R не должно быть медленным.

Основываясь на ответе от @Martin Modrák, я собрал пакет с одним трюком, основанный на некоторых внутренних элементах data.table, специализированных для специализированного случая с целыми числами: fastConcat. (Не смотрите на CRAN в ближайшее время, но вы можете использовать его на свой страх и риск, установив из github repo, msummersgill/fastConcat.)

Вероятно, это может значительно улучшить тот, кто лучше понимает C, но на данный момент он работает в том же случае, что и в обновлении 2 в 2,5 секунды - около 8x быстрее, чем sprintf() и 11.5xбыстрее, чем метод stringi::stri_c(), который я использовал изначально.

Для меня это подчеркивает огромную возможность повышения производительности некоторых простейших операций в R, таких как рудиментарное конкатенация вектор-строк с улучшенной настройкой C. Думаю, такие люди, как @Matt Dowle, видели это годами - если бы у него было время переписать все R, а не только data.frame.

fastConcat


Ответы

Ответ 1

C на помощь!

Похищая некоторый код из data.table, мы можем написать функцию C, которая работает быстрее (и может быть распараллелирована еще быстрее).

Сначала убедитесь, что у вас есть рабочая С++ toolchain с:

library(inline)

fx <- inline::cfunction( signature(x = "integer", y = "numeric" ) , '
    return ScalarReal( INTEGER(x)[0] * REAL(y)[0] ) ;
' )
fx( 2L, 5 ) #Should return 10

Затем это должно работать (при условии, что данные только целочисленные, но код может быть расширен для других типов):

library(inline)
library(data.table)
library(stringi)

header <- "

//Taken from https://github.com/Rdatatable/data.table/blob/master/src/fwrite.c
static inline void reverse(char *upp, char *low)
{
  upp--;
  while (upp>low) {
  char tmp = *upp;
  *upp = *low;
  *low = tmp;
  upp--;
  low++;
  }
}

void writeInt32(int *col, size_t row, char **pch)
{
  char *ch = *pch;
  int x = col[row];
  if (x == INT_MIN) {
  *ch++ = 'N';
  *ch++ = 'A';
  } else {
  if (x<0) { *ch++ = '-'; x=-x; }
  // Avoid log() for speed. Write backwards then reverse when we know how long.
  char *low = ch;
  do { *ch++ = '0'+x%10; x/=10; } while (x>0);
  reverse(ch, low);
  }
  *pch = ch;
}

//end of copied code 

"



 worker_fun <- inline::cfunction( signature(x = "list", preallocated_target = "character", columns = "integer", start_row = "integer", end_row = "integer"), includes = header , "
  const size_t _start_row = INTEGER(start_row)[0] - 1;
  const size_t _end_row = INTEGER(end_row)[0];

  const int max_out_len = 256 * 256; //max length of the final string
  char buffer[max_out_len];
  const size_t num_elements = _end_row - _start_row;
  const size_t num_columns = LENGTH(columns);
  const int * _columns = INTEGER(columns);

  for(size_t i = _start_row; i < _end_row; ++i) {
    char *buf_pos = buffer;
    for(size_t c = 0; c < num_columns; ++c) {
      if(c > 0) {
        buf_pos[0] = ',';
        ++buf_pos;
      }
      writeInt32(INTEGER(VECTOR_ELT(x, _columns[c] - 1)), i, &buf_pos);
    }
    SET_STRING_ELT(preallocated_target,i, mkCharLen(buffer, buf_pos - buffer));
  }
return preallocated_target;
" )

#Test with the same data

RowCount <- 5e6
DT <- data.table(x = "foo",
                 y = "bar",
                 a = sample.int(9, RowCount, TRUE),
                 b = sample.int(9, RowCount, TRUE),
                 c = sample.int(9, RowCount, TRUE),
                 d = sample.int(9, RowCount, TRUE),
                 e = sample.int(9, RowCount, TRUE),
                 f = sample.int(9, RowCount, TRUE))

## Generate an expression to paste an arbitrary list of columns together
ConcatCols <- list("a","b","c","d","e","f")
## Do it 3x as many times
ConcatCols <- c(ConcatCols,ConcatCols,ConcatCols)


ptm <- proc.time()
preallocated_target <- character(RowCount)
column_indices <- sapply(ConcatCols, FUN = function(x) { which(colnames(DT) == x )})
x <- worker_fun(DT, preallocated_target, column_indices, as.integer(1), as.integer(RowCount))
DT[, State := preallocated_target]
proc.time() - ptm

Пока ваш (целочисленный) пример работает примерно за 20 секунд на моем ПК, он работает в ~ 5 секунд и может быть легко распараллелен.

Некоторые примечания:

  • Код не готов к производству - на входы функций должно быть сделано много проверок на работоспособность (особенно если проверить, что все столбцы имеют одинаковую длину, проверку типов столбцов, preallocated_target и т.д.).
  • Функция помещает свой вывод в предварительно выделенный вектор символов, это нестандартный и уродливый (у R обычно нет семантики pass-by-reference), но допускается распараллеливание (см. ниже).
  • Последние два параметра - это начальные и конечные строки, которые нужно обработать, еще раз, это для паралеллизации
  • Функция принимает индексы столбцов, а не имена столбцов. Все столбцы должны иметь тип integer.
  • За исключением входных данных .table и preallocated_target, входы должны быть целыми.
  • Время компиляции для функции не включено (как вы должны ее заранее скомпилировать - возможно, даже сделать пакет)

распараллеливания

РЕДАКТИРОВАТЬ: Подход, приведенный ниже, действительно завершится неудачно из-за работы clusterExport и R string. Параллеллизация, вероятно, также должна быть выполнена на С, подобно тому, как это достигается в таблице данных.

Поскольку вы не можете передавать встроенные функции в R-процессы, паралеллизация требует некоторой дополнительной работы. Чтобы иметь возможность использовать вышеприведенную функцию параллельно, вам либо нужно ее скомпилировать отдельно с компилятором R, либо использовать dyn.load ИЛИ обернуть его в пакет или использовать бэкэнд для параллельной работы (у меня его нет, только для работы с файлами в UNIX).

Выполняется параллельно, тогда будет выглядеть что-то вроде (не проверено):

no_cores <- detectCores()

# Initiate cluster
cl <- makeCluster(no_cores)

#Preallocated target and prepare params
num_elements <- length(DT[[1]])
preallocated_target <- character(num_elements)
block_size <- 4096 #No of rows processed at once. Adjust for best performance
column_indices <- sapply(ConcatCols, FUN = function(x) { which(colnames(DT) == x )})

num_blocks <- ceiling(num_elements / block_size)

clusterExport(cl, 
   c("DT","preallocated_target","column_indices","num_elements", "block_size"))
clusterEvalQ(cl, <CODE TO LOAD THE NATIVE FUNCTION HERE>)

parLapply(cl, 1:num_blocks ,
          function(block_id)
          {
            throw_away <- 
              worker_fun(DT, preallocated_target, columns, 
              (block_id - 1) * block_size + 1, min(num_elements, block_id * block_size - 1))
            return(NULL)
          })



stopCluster(cl)

Ответ 2

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

Это означает, что для выборочных данных вы будете смотреть на ~ 500 тыс. конкатенаций против 10 миллионов, если вы тоже сделаете дубликаты.

См. следующий пример кода и времени:

system.time({
  setkeyv(DT, ConcatCols)
  DTunique <- unique(DT[, ConcatCols, with=FALSE], by = key(DT))
  DTunique[, State :=  do.call(paste, c(DTunique, sep = ""))]
  DT[DTunique, State := i.State, on = ConcatCols]
})
#       user      system     elapsed 
#      7.448       0.462       4.618 

Около половины времени тратится на часть setkey. Если ваши данные уже введены в действие, время сокращается до чуть более 2 секунд.

setkeyv(DT, ConcatCols)
system.time({
  DTunique <- unique(DT[, ConcatCols, with=FALSE], by = key(DT))
  DTunique[, State :=  do.call(paste, c(DTunique, sep = ""))]
  DT[DTunique, State := i.State, on = ConcatCols]
})
#       user      system     elapsed 
#      2.526       0.280       2.181 

Ответ 3

Это использует unite из пакета tidyr. Может быть, не самый быстрый, но, вероятно, быстрее, чем ручной код R.

library(tidyr)
system.time(
  DNew <- DT %>% unite(State, ConcatCols, sep = "", remove = FALSE)
)
# user  system elapsed 
# 14.974   0.183  15.343 

DNew[1:10]
# State   x   y a b c d e f
# 1: foo211621bar foo bar 2 1 1 6 2 1
# 2: foo532735bar foo bar 5 3 2 7 3 5
# 3: foo965776bar foo bar 9 6 5 7 7 6
# 4: foo221284bar foo bar 2 2 1 2 8 4
# 5: foo485976bar foo bar 4 8 5 9 7 6
# 6: foo566778bar foo bar 5 6 6 7 7 8
# 7: foo892636bar foo bar 8 9 2 6 3 6
# 8: foo836672bar foo bar 8 3 6 6 7 2
# 9: foo963926bar foo bar 9 6 3 9 2 6
# 10: foo385216bar foo bar 3 8 5 2 1 6