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

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

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

library(compiler)

Параметры компиляции

Все рассмотренные ранее функции из пакета compiler|core=true имеют опции, которые могут быть переданы в качестве аргументов функциям компиляции (аргумент options), или заданы глобально с помощью функции setCompilerOptions().

Рассмотрим эти опции:

  • optimize - определяет уровень оптимизации: принимает значения от 0 до 3 (по умолчанию 2);
  • suppressAll - управляет сообщениями: принимает значения TRUE или FALSE (по умолчанию code>FALSE`);
  • suppressUndefined - управление сообщения о неопределённых (undefined) переменных: может принимать значения TRUE или список имён переменных (по умолчанию “.Generic”, .Method“,”.Random.seed“,”.self“).

Получить текущее значение глобальных опций компиляции можно с помощью функции getCompilerOption():

getCompilerOption("optimize")
#> [1] 2
getCompilerOption("suppressAll")
#> [1] FALSE
getCompilerOption("suppressUndefined")
#> [1] ".Generic"     ".Method"      ".Random.seed" ".self"

Компиляция функций и выражений

Скомпилировать отдельно взятое выражение можно с помощью функции compile(). Полученный обхект имеет класс bytecode и по сути является аналогом класса expression. Чтобы выполнить данные выражение необходимо воспользоваться функцией eval().

В качестве примера возьмём цикл, которые последовательно складывает 2 числа.

# объявляем переменные
s <- as.double(0)
x <- as.double(1:1000)
# объявляем выражения
expr <- expression(for (i in x) s <- s + i)
expr_c <- compile(for (i in x) s <- s + i)

Убедимся, что наши выражения возвращают идентичный результат:

identical(eval(expr), eval(expr_c))
#> [1] TRUE

Для сравнения скорости выполнения кода мы традиционно используем пакет microbenchmark.

library(microbenchmark)
microbenchmark(eval(expr), eval(expr_c))
#> Unit: nanoseconds
#>          expr    min     lq   mean median     uq    max neval cld
#>    eval(expr) 183259 187080 206113 198400 209267 772224   100   b
#>  eval(expr_c)    916   1230   1587   1370   1570   5827   100  a

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

# old R version of lapply
la1 <- function(X, FUN, ...) {
    FUN <- match.fun(FUN)
    if (!is.list(X))
        X <- as.list(X)
    rval <- vector("list", length(X))
    for(i in seq(along = X))
        rval[i] <- list(FUN(X, ...))
    names(rval) <- names(X)               # keep `names' !
    return(rval)
}
# a small variation
la2 <- function(X, FUN, ...) {
    FUN <- match.fun(FUN)
    if (!is.list(X))
        X <- as.list(X)
    rval <- vector("list", length(X))
    for(i in seq(along = X)) {
        v <- FUN(X, ...)
        if (is.null(v))
            rval[i] <- list(v)
        else
            rval <- v
    }
    names(rval) <- names(X)               # keep `names' !
    return(rval)
}

Скомпилируем эти функции в бат-код:

la1c <- cmpfun(la1)
la2c <- cmpfun(la2)
lapplyc <- cmpfun(lapply)

Сравним производительность этих функций:

x <- 1:1000
microbenchmark(lapply(x, is.null), la1(x, is.null), la2(x, is.null),
               lapplyc(x, is.null), la1c(x, is.null), la2c(x, is.null))
#> Unit: microseconds
#>                 expr   min     lq   mean median     uq  max neval  cld
#>   lapply(x, is.null) 175.1  183.8  202.9  187.8  203.6 1007   100 a   
#>      la1(x, is.null) 808.5  832.7  936.5  865.8  891.8 1815   100   c 
#>      la2(x, is.null) 969.7 1004.3 1141.9 1056.3 1081.8 2012   100    d
#>  lapplyc(x, is.null) 177.1  183.3  209.0  189.2  201.1  982   100 a   
#>     la1c(x, is.null) 401.1  421.4  453.4  433.7  448.2 1392   100  b  
#>     la2c(x, is.null) 426.5  440.2  481.8  451.0  465.9 1993   100  b

Обращается на себя внимание, что скомпилированная версия функции lapply() не превосходит по производительности оригинальную версию. Происходит это потому, что функция lapply() использует написанную на C, скомпилированную функцию. Также отметим, что скомпилированные версии функций la1() и la2() показываются практически одинаковую производительность, тогда как нескомпилированные версии довольно сильно различались. Это достигается за счёт высокой оптимизации работы циклов при компиляции функций.

Компиляция скриптов

Ещё одна возможность, предоставляемая пакетом compiler|core=true - это компиляция R-скриптов. Создание скомпилированных файлов осуществляется с помощью функции , а загрузка скомпилированных файлов с помощью функции loadcmp(). Если выходной файл не указан, то выходной файл имеет тоже имя, что и входной, но с расширением .Rc. Пример использования:

cmpfile("script.R", "script.Rc")
loadcmp("script.Rc")

JIT-компиляция

Помимо компиляции отдельных функций и выражений, пакет compiler|core=true предоставляет возможность JIT-компиляции (JIT - just-in-time) или компиляции «на лету», т.е. во время непосредственного выполнения кода. Переключение режима осуществляется с помощью функции enableJIT(). Эта функции имеет всего один аргумент (level), который может принимать одно из трёх значений:

  • 0 — отключение JIT-компиляции;
  • 1 — компиляции функций до их первого вызова;
  • 2 — тоже что и 2 плюс компиляция всех циклов for, while, repeat до их вызова.

Отметим, что при включении JIT-компиляции при первом запуске выполнения кода компиляция всех функций и циклов займёт некоторое время.