Производительность кода

Время выполнения кода

При написании сложных функций и нетривиальном процессинге данных необходимо контролировать время выполнения кода и корректировать те операции, которые занимают слишком много времени или памяти. Для анализа времени выполнения кода в R можно использовать как базовую функцию system.time(), так и функции более подходящих пакетов bench или microbenchmark.

Функция system.time() является обёрткой над двумя вызовами proc.time() и возвращает, сколько времени заняло выполнение выражения. user time (пользователь) — сколько времени потребовалось для выполнения всего выражения (в том числе парсинг выражения и запуск процесса), system time (система) — сколько потребовалось времени процессора для выполнения процесса операционной системой.

set.seed(123)
system.time(for(i in 1:1000) log(runif(1000)))
##    user  system elapsed 
##   0.043   0.001   0.042

Несмотря на простоту, system.time() не лучший инструмент для измерений: как минимум, могут быть параллельные процессы, более приоритетные для системы. При единичном измерении легко ошибиться, и даже аргумент gcFirst = TRUE, вызывающий сборщик мусора при выполнении, неконтролируемо полезен. В качестве альтернативы, лучше пользоваться функциями microbenchmark::microbenchmark() и bench::mark(), которые выполняют измерение заданное количество раз и в выводе возвращают опиcательные статистики по времени выполнения выражения.

Функция microbenchmark() одноимённого пакета измеряет время выполнения выражения в заданном количестве испытаний и возвращает квартили распределения времени выполнения. Обычно бенчмарки используют для сравнения нескольких разных выражений (функций). Неоптимальный цикл в функции f1() задан умышленно:

library(microbenchmark)
f1 <- function(n) {
  res <- vector(mode = 'numeric')
  for (i in 1:n) res <- c(res, log(runif(1)))
  res
}

f2 <- function(n)
  sapply(seq_len(n), function(x) log(runif(1)))


microbenchmark(f1(1000), f2(1000), times = 100)
## Unit: milliseconds
##      expr      min       lq     mean   median       uq      max neval cld
##  f1(1000) 2.921679 3.086750 3.841297 3.188077 3.348235 8.404629   100   b
##  f2(1000) 2.193340 2.313839 2.809881 2.382432 2.526168 7.831445   100  a

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

library(bench)
bench_res <- mark(f1(100), iterations = 100)
bench_res
## # A tibble: 1 × 6
##   expression      min   median `itr/sec` mem_alloc `gc/sec`
##   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
## 1 f1(100)       211µs    316µs     3316.     292KB     33.5

Расширение функции mark(), функция press(), позволяет использовать mark() в указанном диапазоне параметров. То есть вместо цикла с перебором количества итераций можно просто воспользоваться press():

press(rep = c(1, 10, 100), mark(f1(100)))
## Running with:
##     rep
## 1     1
## 2    10
## 3   100
## # A tibble: 3 × 7
##   expression   rep      min   median `itr/sec` mem_alloc `gc/sec`
##   <bch:expr> <dbl> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
## 1 f1(100)        1    204µs    219µs     4273.     292KB     30.8
## 2 f1(100)       10    203µs    234µs     3570.     292KB     25.9
## 3 f1(100)      100    202µs    217µs     4441.     292KB     31.1

Профилирование кода

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

В базовом R есть функция Rprof(), с помощью которой можно записать результаты профилирования в файл, чтобы потом с помощью summaryRprof() его прочитать и проинтерпретировать. Тем не менее, ощутимо удобнее использовать пакет profvis, который также использует Rprof()/summaryRprof(), но результаты представляет в удобном интерактивном виде.

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

# определяем функцию
numbers <- function(x) {
  x <- as.character(x)
  x <- gsub('[^0-9]', '', x)
  x <- strsplit(x, '')
  x <- unlist(x)
  digit_vector <- factor(x, levels = 0:9)
  res <- table(digit_vector)
  as.numeric(res)
}

# пример работы функции
numbers(c('097-8', '97', '2-25-0'))
##  [1] 2 0 2 0 0 1 0 2 1 2

Для того чтобы отследить время выполнения каждого выражения функции, передадим в profvis() не саму функцию numbers(), а её тело (при работе с большими скриптами можно просто указывать source(your_script.R)).

library(profvis)
vec <- sample(10000, 1e5, replace = TRUE)
profvis(
  expr = {
    x <- as.character(vec)
    x <- gsub('[^0-9]', '', x)
    x <- strsplit(x, '')
    x <- unlist(x)
    digit_vector <- factor(x, levels = 0:9)
    res <- table(digit_vector)
    as.numeric(res)
  }, 
  height = 500
)

Rprof() и, следовательно, profvis() не напрямую измеряют время выполнение кода, а с определённым интервалом записывают состояние интерпретатора (для profvis() это 10 мс по умолчанию), какое выражение обрабатывается в этом интервале. Как следствие, для профилирования очень быстрых выражений потребуется определённым образом замедлить выполнение, например, увеличить длину вектора, как в примере выше. Без такого замедления можно получить ошибку Error in parse_rprof(prof_output, expr_source) : No parsing data available. Maybe your function was too fast?.

Оптимизация кода

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

Оптимизация циклов

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

Например, у нас есть строковый вектор, каждый элемент которого мы хотим разделить по какому-то символу с помощью strsplit() и извлечь вторую часть каждого элемента.

# создаём строковый вектор
N <- 1e4
set.seed(12345)

uids <- paste0('uid_', sample(N), sample(letters, N, TRUE))
uids[1:3]
## [1] "uid_8243b" "uid_720t"  "uid_8922g"
# для примера из первого элемента извлекаем второе значение
uids_splitted <- strsplit(uids, '_')
uids_splitted[[1]][2]
## [1] "8243b"

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

uids_second <- c()
for (i in seq_len(N))
  uids_second <- c(uids_second, uids_splitted[[i]][2])
uids_second[1:3]
## [1] "8243b" "720t"  "8922g"

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

uids_second <- c()
for (i in seq_len(3)) {
  uids_second <- c(uids_second, uids_splitted[[i]][2])
  print(tracemem(uids_second))
}
## [1] "<0x55b853185d58>"
## [1] "<0x55b8566547d8>"
## [1] "<0x55b8570d1208>"

Корректным в данном случае было бы создание объекта и изменение его во время итераций. В англоязычной литературе по R этот ход обычно называют memory preallocation, что не очень точно, так как в данном случае не происходит реального выделения памяти, а всего лишь избегается копирование и удаление объектов на каждой итерации.

Целевой вектор мы создаём с помощью vector() и изменяем его по индексам. Для примера возьмём только первые три элемента, чтобы не удлинять цикл, и посмотрим, как меняется адрес объекта uids_second в памяти.

uids_second <- vector('character', 3)
for (i in seq_len(3)) {
  uids_second[i] <- uids_splitted[[i]][2]
  print(tracemem(uids_second))
}
## [1] "<0x55b856672a78>"
## [1] "<0x55b856672a78>"
## [1] "<0x55b856672a78>"
uids_second
## [1] "8243b" "720t"  "8922g"

Неявные циклы, к которым относится семейство функций *pply (lapply() в частности), работают практически аналогично циклам с предварительным выделением памяти, только с рядом дополнительных оптимизаций, в первую очередь за счёт вызова С-функции и преаллокации на уровне C.

Сравним скорость трёх вариантов цикла, различие на порядки:

loops_bench <- microbenchmark(
  'for + c()' = {
    uids_second <- c()
    for (i in seq_len(N))
      uids_second <- c(uids_second, uids_splitted[[i]][2])
    },
  'for + vector()' = {
    uids_second <- vector('character', N)
    for (i in seq_len(N))
      uids_second[i] <- uids_splitted[[i]][2]
    },
  'lapply()' = unlist(lapply(uids_splitted, '[[', 2)),
  times = 100
)
print(loops_bench, signif = 3)
## Unit: milliseconds
##            expr    min     lq   mean median     uq    max neval cld
##       for + c() 324.00 334.00 356.00 340.00 357.00 496.00   100   b
##  for + vector()   5.53   5.79   6.11   5.94   6.17   9.74   100  a 
##        lapply()   3.44   3.61   3.89   3.73   3.89   8.06   100  a

Векторизация выражений

Ещё один способ ускорить выполнение выражений — не игнорировать особенности языка. В частности, в R очень активно используется векторизация, когда функция применяется ко всем элементам вектора.

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

Сравним самый быстрый вариант цикла lapply() и решение задачи с помощью gsub(). Для корректности внесём в lapply() ещё и разбивку строковых элементов вектора на части (собственно, эта функция также векторизована). В предыдущем сравнении циклов эта операция была вынесена и не учитывалась в бенчмарках. Как мы видим, решение задачи с помощью более релевантной языковой конструкции оказывается эффективнее, чем перебор элементов даже в оптимальном цикле.

loops_bench <- microbenchmark(
  'lapply()' = unlist(lapply(strsplit(uids, '_'), '[[', 2)),
  'gsub()' = gsub('^.*_', '', uids) ,
  times = 100
)
print(loops_bench, signif = 3)
## Unit: milliseconds
##      expr   min    lq  mean median    uq  max neval cld
##  lapply() 12.20 13.00 13.60  13.30 13.60 23.7   100   b
##    gsub()  4.66  5.19  5.48   5.37  5.67 11.1   100  a

Параллелизация кода

Помимо просто написания хорошего кода с правильными конструкциями, можно распараллеливать выполнение кода — выполнять одинаковые операции в доступных параллельных потоках в зависимости от количества ядер процессора или количества процессоров. При этом некоторые пакеты, в частности, data.table, по умолчанию используют половину ядер (и есть отдельная опция для этого).

Для параллелизации прочих частей кода можно воспользоваться как базовой функцией mclapply() (на Windows использует только одно ядро, адекватная замена — parLapply()), которая обращается к пакету parallel, так и спецпакетами foreach и doParallel, которые служат надстройкой над parallel — первый пакет необходим, собственно, для параллелизации, второй служит бэкендом для него.

library(foreach)
library(doParallel)
## Loading required package: iterators
## Loading required package: parallel
# задаём количество проб и готовим датасет
trials <- 100
iris_df <- iris[iris$Species != 'setosa', ]

# пишем функцию оценки lm-коффициентов на семпле
lm_bootstrap <- function(df) {
  iris_sample <- df[sample(100, replace = TRUE), ]
  lm_fit <- glm(Species ~ Sepal.Length, iris_sample, family = binomial)
  coefficients(lm_fit)
}

# указываем, какое количество ядер использовать в параллелизации
cores <- detectCores()
cl <- makeCluster(cores)
registerDoParallel(cl)
getDoParWorkers()
## [1] 8
# оцениваем каждое выражение
parallel_bench <- microbenchmark(
  'lapply' = lapply(seq_len(trials), function(x) lm_bootstrap(iris_df)),
  'mclapply()' = mclapply(seq_len(trials), function(x) lm_bootstrap(iris_df)),
  'foreach()' = foreach(icount(trials)) %dopar% lm_bootstrap(iris_df),
  times = 10
)
# останавливаем параллелизацию по ядрам
stopCluster(cl)

# смотрим результат
print(parallel_bench, signif = 3)
## Unit: milliseconds
##        expr min  lq mean median  uq max neval cld
##      lapply 152 169  182    183 199 202    10   b
##  mclapply() 121 122  127    125 129 150    10  a 
##   foreach() 113 121  124    124 127 138    10  a

Как мы видим, mclapply(), работает сопоставимо с foreach(). В целом, вполне может получаться в некоторых случаях так, что foreach() будет чуть-чуть медленнее. Это объяснимое поведение, так как при работе foreach() появляются дополнительные расходы на запуск и взаимодействие с кластером. Как следствие, foreach() может быть не очень эффективен для параллелизации некоторых (обычно быстрых) операций.

Снижение накладных расходов

Большинство функций пакетов, помимо основных операций, содержат и дополнительные: проверки типов входных данных, обработку ошибок, проверку на наличие и применение дополнительных аргументов и так далее. Это, конечно же, повышает надёжность кода, однако может увеличивать время выполнения.

Достаточно легко роль таких сопутствующих операций можно увидеть на примере вычисления арифметического среднего. Функция mean() — обобщённый метод (в данном случае функция расчёта среднего) для большого набора классов, для каждого из которых метод может быть реализован по-своему. Соответственно, функция mean() будет сначала определять, с каким классом объекта имеет дело, и потом только вызывать соответствующий метод. Функция mean.default() - это метод по умолчанию, когда конкретная имплементация не найдена или у объекта вообще отсутствует атрибут класса. Соответственно, mean.default() должна быть немного быстрее mean(). К тому же обе функции имеют свои допаргументы (trim, na.rm или ...), поэтому будут медленнее, чем прямолинейное вычисление среднего по вектору как отношение суммы элементов и длины вектора.

vec <- rnorm(1e6)

mean_bench <- microbenchmark(
  'mean()' = mean(vec),
  'mean.default()' = mean.default(vec),
  'sum() / len()' = sum(vec) / length(vec),
  times = 100
)
print(mean_bench, signif = 3)
## Unit: microseconds
##            expr  min   lq mean median   uq  max neval cld
##          mean() 1850 1980 2100   2080 2150 3000   100   b
##  mean.default() 1840 1990 2080   2060 2150 2660   100   b
##   sum() / len()  933  976 1040   1010 1060 1550   100  a

Вставки на С++

Ещё одним эффективным методом оптимизации кода может быть использование вставок на других языках программирования, в частности, на C++ (несмотря на наличие интерфейсов к другим языкам, например, Java или Python). Для вставок C++ используется пакет Rcpp или его аналоги.

Сравним функцию вычисления среднего c помощью функций R и с помощью вставки на C++.

# include <Rcpp.h>
using namespace Rcpp;

// [[Rcpp::export]]
double mean_Rcpp(NumericVector x) {
  int n = x.size();
  double total = 0;
  
  for(int i = 0; i < n; ++i) {
    total += x[i];
  }
  return total / n;
}

Как мы видим, функция получилась быстрая, но всё равно чуть-чуть медленнее, чем базовая реализация sum() / len() на R.

library(Rcpp)
mean_bench <- microbenchmark(
  'mean()' = mean(vec),
  'mean.default()' = mean.default(vec),
  'sum() / len()' = sum(vec) / length(vec),
  'mean_Rcpp()' = mean_Rcpp(vec),
  times = 100
)
print(mean_bench, signif = 3)
## Unit: microseconds
##            expr  min   lq mean median   uq  max neval cld
##          mean() 1840 1930 2000   2000 2060 2240   100   b
##  mean.default() 1830 1940 2000   2000 2070 2200   100   b
##   sum() / len()  920  956  996    990 1020 1240   100  a 
##     mean_Rcpp()  913  935 1030   1030 1080 2380   100  a