Порой возникает необходимость оценить время выполнения скрипта, функции или участка кода с целью оптимизации или выявления «узких» мест. Это сообщение посвящено обзору инструментов измерения производительности R-кода.

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

  • Функция system.time() из пакета base;
  • Cпециализированные функции (benchmarks, бенчмарки);
  • Функция профилирования времени выполнения кода Rprof() из пакета utils.

Функция system.time()

Самый простой инструмент для измерения времени выполнения кода — функция system.time() из пакета base. В качестве аргумента функция system.time() принимает выражения и возвращает время выполнения данного выражения. В основе system.time() используется функция proc.time(), которая показывает время, прошедшее с начала запуска R-сессии. Принцип работы функции system.time() очень простой: делаются замеры до и после выполнения выражения, затем первый замер вычитается из второго.

Измерим время выполнения функции Sys.sleep(), которая останавливает выполнение кода на заданный интервал времени (в секундах):

system.time(Sys.sleep(1))
#> пользователь      система       прошло 
#>        0.000        0.000        1.001

Как видим, на выполнение данной операции заняло ровно одну секунду.

Приведём ещё один пример. Сравним время вычисления встроенной в R функции mean() и среднего, вычисленного по формуле $\frac{1}{n}\sum_{i=1}^{n}x_{i}$. на сгенерированном массиве нормально распределенных значений:

x <- rnorm(10^5L)
system.time(mean(x))
#> пользователь      система       прошло 
#>            0            0            0
system.time(sum(x) / length(x))
#> пользователь      система       прошло 
#>            0            0            0

Функция system.time() возвращает 3 значения:

  • user - время CPU, которое занял пользователь;
  • system - время CPU, которое заняла система;
  • elapsed - реальное время, которое заняло выполнение команды.

Соответственно, в выводе функции нас интересует значение elapsed, который показывает время выполнения функции (выражения) в секундах. Как мы видим из предыдущего примера, внешне более сложное выражение sum(x) / length(x) выполняется быстрее стандартной функции mean(x).

Обратим внимание на то, что минимальный инетрвал времени, который может зафиксировать функция system.time() — это 1/1000 секунды. Т.е. выражения, которые выполянются быстрее 1/1000 секунды, не будут зафиксированы функцией system.time(). Одним из способов обойти это ограничение может быть многократное повторение выражения и фиксация общего времени выполнения всех повторений.

К сожалению, подобный способ достаточно ненадежен, так как для оценки времени выполнения выражения функция system.time() обращается к системным значениям времени. Следовательно, если во время выполнения кода параллельно производятся и другие операции на компьютере (а такое случается практически в ста процентах случаев), то возможно увеличение времени выполнения R-кода. Некоторую вариативность результатов можно увидеть, даже если выполнить функцию system.time() несколько раз подряд. Подобной неточности оценки можно избежать путём многократного повторения выполняемых выражений и вычислением среднего времени, что позволит сгладить часть вариаций.

Проиллюстрируем вышесказанное на примере:

replicate(10, system.time(mean(x)))
#>  [1] 0.000 0.000 0.001 0.001 0.000 0.000 0.000 0.000 0.000 0.001

В этом примере с помощью функции replicate() мы повторили выражение system.time(mean(x)) 10 раз, отфильтровав вывод функции system.time() так, чтобы нам выводилось только время выполнения команды, дописав . Как мы видим, время выполнения при повторном выполнении выражения может отличаться.

Базовый пакет позволяет реализовать процедуру многократного повторения выражения функции как минимум двумя способами. Первый — это функция replicate(). Приведенное выше сопоставление времени выполнения двух выражений при использовании функции replicate() будет выглядеть следующим образом:

system.time(replicate(100, mean(x)))
#> пользователь      система       прошло 
#>        0.013        0.000        0.015
system.time(replicate(100, sum(x) / length(x)))
#> пользователь      система       прошло 
#>        0.010        0.000        0.008

Тот же самый эффект можно получить и с помощью обычного цикла for():

system.time(for (i in seq_len(100)) mean(x))
#> пользователь      система       прошло 
#>        0.013        0.000        0.015
system.time(for (i in seq_len(100)) sum(x) / length(x))
#> пользователь      система       прошло 
#>        0.007        0.000        0.007

Можно также использовать описательные статистики в сочетании с множественными повторениями:

median(replicate(100, system.time(mean(x))))
#> [1] 0

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

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

Пакет rbenchmark

Основа пакета rbenchmark — функция benchmark(). Данная функция работает следующим образом: указанные в качестве аргументов выражения выполняются заданное количество раз (по умолчанию 100) и вычисляется время, затраченное на выполнение всех попыток. В качестве аргументов функции benchmark() необходимо передать выражения или функции, а также количество повторений, передаваемых аргументом replications (анализ функции benchmark() показал, что, данная функция использует system.time() и replicate(), рассмотренные в предыдущем разделе).

Для примера возьмём несколько способов расчёта среднего арифметического для сгенерированного массива данных.

x <- replicate(10, rnorm(10^4L))

Использованные нами способы — функции векторизованных вычислений (apply(), vapply()), стандартный цикл и специальная функция вычисления средних по столбцам ColMeans(). Представим эти способы в виде самостоятельных функций для удобства их вызова при работе с benchmark():

apply_means <- function(x) {
  apply(x, 2, mean)
}

loop_means <- function(x) {
  n.vars <- ncol(x)
  res <- double(n.vars)
  for (i in seq_len(n.vars))
    res[i] <- mean(x[, i])
  return(res)
}

Убедимся, что функции возвращают одинаковый результат. Сделать это можно с помощью функций identical() или all.equal():

identical(apply_means(x), loop_means(x), colMeans(x))
#> [1] TRUE

Теперь, подключив пакет rbenchmark, мы можем сравнить время работы каждого из выбранных нами способов вычисления средних по столбцам:

library(rbenchmark)
benchmark(apply_means(x), loop_means(x),
          colMeans(x), replications = 100)
#>             test replications elapsed relative user.self sys.self user.child sys.child
#> 1 apply_means(x)          100   0.215    26.88     0.213        0          0         0
#> 3    colMeans(x)          100   0.008     1.00     0.010        0          0         0
#> 2  loop_means(x)          100   0.093    11.62     0.090        0          0         0

Наиболее важны для нас в выводе функции benchmark() столбцы elapsed и relative. Столбец elapsed показывает время в секундах, затраченное на выполнение интересующей нас функции. Как видим из примера, самыми медленными оказались функции apply_means() и loop_means(), а самой быстрой colMeans().

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

Для более удобного просмотра можно отфильтровать вывод функции benchmark() с помощью аргумента columns. Также может быть полезен аргумент order, позволяющий отсортировать вывод по любому из столбцов. Для примера зададим набор показателей, которые мы хотим включить в таблицу (в данном случае это «test», «replications», «elapsed», «relative»), и отсортируем выдачу по столбцу «elapsed» по возрастанию значений:

benchmark(apply_means(x), loop_means(x), colMeans(x),
          replications = 100, order = "relative",
          columns = c("test", "replications", "elapsed", "relative"))
#>             test replications elapsed relative
#> 3    colMeans(x)          100   0.008     1.00
#> 2  loop_means(x)          100   0.094    11.75
#> 1 apply_means(x)          100   0.132    16.50

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

Чтобы не указывать нужные столбцы каждый раз, когда используется функция benchmark(), можно закрепить заданный формат выдачи результатов (далее используется именно такой формат вывода, с сортировкой по столбцу «relative»). Для этого следует воспользоваться функцией formals():

formals(benchmark)$columns <- c("test", "replications", "elapsed", "relative")
formals(benchmark)$order <- "relative"

Таким образом, пакет rbenchmark хоть и использует функцию system.time(), но преодолевает её ограничения (минимальный фиксируемый интервал времени в 1/1000 секунды и вариативность результатов при многократном выполении) путём многократных повторений и расчёте общего времени выполнения всех повторений.

Пакет microbenchmark

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

  • Возможность измерения времени выполнения выражения вплоть до наносекунд;
  • Возможность контролировать последовательность выполнения выражений: случайно или последовательно;
  • Возможность проведения предварительных испытаний («прогрев») до начала процесса измерений.

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

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

library(microbenchmark)
res <- microbenchmark(apply_means(x), loop_means(x), colMeans(x), times = 100)
print(res, unit = "ms", order = "median")
#> Unit: milliseconds
#>            expr     min      lq    mean  median      uq     max neval cld
#>     colMeans(x) 0.07689 0.07781 0.08593 0.08396 0.09191  0.1109   100  a 
#>   loop_means(x) 0.81383 0.82063 1.01602 0.82963 0.85081  1.9489   100  ab
#>  apply_means(x) 1.04515 1.06409 2.01343 1.07684 1.10923 78.1016   100   b

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

Вся полученная информация о попытках применения функций вычисления средних записана в отдельную переменную res. С помощью функции str() можно увидеть структуру переменной:

str(res)
#> Classes 'microbenchmark' and 'data.frame':   300 obs. of  2 variables:
#>  $ expr: Factor w/ 3 levels "apply_means(x)",..: 2 1 1 3 3 1 2 2 2 2 ...
#>  $ time: num  865087 1076468 1049715 92776 77721 ...

Переменная res, как можно увидеть в выводе функции str(), представляет собой список (list) и включает в себя две переменные: expr (выражение) и time (время выполнения). На основе этой информации и рассчитываются описательные статистики, приведённые в примере применения функции microbenchmark(). Наличие исходных данных о каждой попытке позволяет самостоятельно выбирать, рассчитывать и сравнивать предпочтитаемые показатели. Например, расчет медианного времени выполнения попытки и общего времени выполнения всех попыток для каждого выражения выглядит следующим образом:

aggregate(time ~ expr, data = res, function(x) median(x) * 10^-6L)
#>             expr    time
#> 1 apply_means(x) 1.07684
#> 2  loop_means(x) 0.82963
#> 3    colMeans(x) 0.08396
aggregate(time ~ expr, data = res, function(x) sum(x) * 10^-6L)
#>             expr    time
#> 1 apply_means(x) 201.343
#> 2  loop_means(x) 101.602
#> 3    colMeans(x)   8.593

Умножение на ( 10^{-6} ) — это перевод в миллисекунды. Чтобы получить секунды, нужно, соответственно, умножить на ( 10^{-9} ).

Помимо настройки формата вывода, выбора показателей, наличие информации о времени выполнения выражения в каждой попытке, пакет microbenchmark позволяет визуализировать результаты оценки времени выполнения выражения. Например, с помощью функции autoplot() из пакета ggplot2, можно получить следующий график:

library(ggplot2)
autoplot(res)

Ещё один довольно интересный способ графического представления результатов измерения скорости выполнения кода с помощью функции qplot() представлен ниже:

qplot(y = time, data = res, colour = expr)

Мы также можем построить обычую диаграмму размаха (boxplot):

boxplot(time ~ expr, data = res, outline = FALSE)

Так же можно, если возникнет необходимость, оценить статистическую значимость различий во времени выполнения выражений. Благодаря тому, что в переменной res хранятся данные о времени выполнения каждой попытки из заданного числа, становится возможным использование статистических критериев. Выбор критерия — на усмотрение аналитика, в примере ниже использовался параметрический критерий сравнения групп t-Стьюдента с поправкой уровня статистической значимости Холма для множественных сравнений:

pairwise.wilcox.test(res$time, res$expr)
#> 
#>  Pairwise comparisons using Wilcoxon rank sum test 
#> 
#> data:  res$time and res$expr 
#> 
#>               apply_means(x) loop_means(x)
#> loop_means(x) 6.1e-16        -            
#> colMeans(x)   < 2e-16        < 2e-16      
#> 
#> P value adjustment method: holm

Таким образом, функция microbenchmark() является оптимальным инструментом для анализа проивзодительности кода. При этом минимально фиксируемый интервал времени — 1 наносекунла, что позволяет тестирования даже небольшие, быстровыполняющиеся участки кода. Также в пакете microbenchmark решены проблемы «холодного» старта путём предварительного выполенения выражения и случайного порядка выполнения при тестировании. Также в пакете реализована возможность визуализации результатов и оценки статистической значимости различий.