За последнее десятилетие R прошёл большой путь: от нишевого (как правило, академического) инструмента до мейнстримной «большой десятки» самых популярных языков программирования. Такой интерес вызван многими причинами, среди которых и принадлежность к open source, и деятельное коммьюнити, и активно растущий сегмент применения методов machine learning / data mining в разнообразных бизнес-задачах. Приятно видеть, когда один из твоих любимых языков уверенно завоёвывает новые позиции, и когда даже далёкие от профессиональной разработки пользователи начинают интересоваться R. Но здесь есть, однако, одна большая проблема:  язык R написан, по выражению создателей, «статистиками для статистиков». Поэтому он высокоуровневый, гибкий, с огромным и надёжным функционалом «из коробки», не говоря уже о тысячах дополнительных пакетов; и одновременно с этим – весьма фривольный по части семантики, соединящий в себе принципиально разные парадигмы и обладающий широкими возможностями для написания чудовищно неэффективного кода. А если учесть тот факт, что для многих в последнее время это первый «настоящий» язык программирования, то ситуация становится ещё более угрожающей. Впрочем, улучшение качества и скорости исполнения кода на R – универсальная задача, с которой постоянно сталкиваются не только новички, но и опытные пользователи. Именно поэтому очень важно время от времени возвращаться к самым простым структурам и основным принципам работы R, чтобы помнить об источниках неэффективностей и способах их устранения. В этой статье я расскажу об одном кейсе, который сначала встретился мне в ходе работы над исследованием финансовых стратегий, а потом был адаптирован мной в качестве учебного для онлайн-курса. Опуская все нерелевантные подробности, мы можем сформулировать нашу тестовую задачу следующим образом. Для произвольного натурального  необходимо быстро генерировать матрицы размером  вот такого вида (приведён пример для ): Это частный случай так называемых ганкелевых матриц. Здесь левый верхний и правый нижний углы всегда содержат единичку, вся побочная диагональ (от левого нижнего до правого верхнего угла) состоит из числа , а все остальные значения расположены ступеньками. В итоге имеем нечто похожее на горный хребет или скат крыши. Естественно ожидать, что параметр  может варьироваться в некотором разумном диапазоне, скажем, от единиц до сотен. Поэтому решение задачи мы будем офомлять в виде отдельной функции, а  будет выступать в ней аргументом. Простейший подсчёт показывает, что нужная нам матрица определяется поэлементно при помощи условия . Если бы мы писали на одном из классических императивных языков (таких, как C или C++), то решение на этом было бы закончено, поскольку оно сводится к элементарному двойному циклу. На R это «наивное решение» выглядит так: Даже несмотря на то, что мы заранее инициализируем матрицу нужного размера (отсутствие первой строки – преаллокации памяти – частая ошибка новичков), такое решение работает весьма медленно. Для знатоков языка такой результат вполне предсказуем: поскольку R – интерпретируемый язык, каждая итерация цикла влечёт множество накладных расходов на исполнение. Примеры с неоптимальным использованием циклов встречаются, пожалуй, в каждом учебнике по R. И вывод всегда один: по возможности использовать так называемые векторизированные функции. Они обычно реализованы на низком уровне (на C) и предельно оптимизированы. Так, в нашем случае, когда элемент матрицы есть функция от двух индексов  и , хорошо подойдёт сочетание функций outer и pmin: Функция pmin отличается от min тем, что она оперирует векторами и на входе, и на выходе. Сравните: min(3:0, 1:4) ищет общий минимум (ноль в данном случае), а pmin(3:0, 1:4) вернёт вектор из попарных минимумов: (1, 2, 1, 0). Понятие векторизированной функции непросто сформулировать формально, но общая идея, надеюсь, понятна. Именно такую функцию ожидает в качестве аргумента функция outer. Именно поэтому передать в неё min, как в наивном решении, не получится – попробуйте это сделать и убедитесь, что возникнет ошибка. К слову, если мы всё-таки хотим использовать сочетание outer и min, мы можем выполнить принудительную векторизацию функции min, обернув её в функцию Vectorize: Надо помнить при этом, что принудительная векторизация такого рода всегда будет работать медленнее, чем естественная. Однако в некоторых ситуациях без Vectorize не обойтись – например, если определение элемента матрицы содержит условный оператор if. Ещё один способ избавиться неэффективности наивного решения – каким-то образом убрать вложенность циклов – сводится к тому, чтобы итерировать не по единичным элементам матрицы, а по векторам или подматрицам. Вариантов может быть много, я покажу только один из них, рекурсивный. Мы будем строить матрицу «поэтажно», от центра к краям: В этом случае эффективность достигается за счёт того, что количество высокоуровневых итераций не , как в наивном решении, а только  (роль цикла на себя берёт стек вызовов). Наконец, в том случае, когда оптимизация на уровне базовых функций R не приводит к желаемому результату, всегда есть ещё один вариант: реализовать медленную часть на максимально низком уровне. То есть написать кусочек кода на чистом C или С++, скомпилировать его и вызывать из R. Такой подход популярен среди разработчиков пакетов в силу своей чрезвычайной эффективности и простоты, а также потому, что для работы с C++ существует замечательный пакет Rcpp. Когда мы подобным образом вызываем cppFunction, произойдёт компиляция кода на C++, и по её завершении мы получим функцию build_cpp в глобальном окружении, которую можно вызывать как любую другую определённую нами функцию. Из очевидных минусов такого подхода можно отметить необходимость компиляции (это тоже занимает время), не говоря уже о том, что необходимо владеть языком C++. Разумеется, использование Rcpp не является «серебряной пулей», и написать медленный код на C/C++ тоже довольно просто. Когда мы рассуждали о быстродействии решений, мы опирались на априорные знания о том, как устроен язык. Теория теорией, но любые рассуждения о производительности должны сопровождаться экспериментами – измерением времени исполнения на конкретных примерах. Более того, в подобных случаях всегда лучше всё проверять и перепроверять самому, потому что любые такие замеры не являются абсолютной истиной: они зависят от железа и состояния процессора, от операционной системы и её нагрузки в момент измерения. Есть несколько типичных способов измерения скорости исполнения кода на R. Один из наиболее распространённых – это функция system.time. Когда используют эту функцию, обычно делают что-то вроде system.time(replicate(expr, 100)). Это неплохой вариант, но есть несколько пакетов, которые занимаются тем же самым, но в гораздо более удобном и настраиваемом исполнении. Фактически стандартом является пакет под названием microbenchmark, его мы и будем использовать. Всю работу будет выполнять одноимённая функция, которой можно передать произвольное количество выражений (см. unevaluated expression) для измерения. По умолчанию каждое выражение будет выполнено сто раз, и мы увидим собранную статистику по времени исполнения. В полученном выводе результаты замера представлены в виде таблицы с набором описательных статистик. Кроме того, полученный объект можно рисовать: plot(m) или ggplot2::autoplot(m). В настоящее время в разработке находится ещё один пакет, предназначенный для замера времени исполнения выражений – пакет benchr. Функционал и синтаксис практически идентичен пакету microbenchmark, но с некоторыми ключевыми отличиями. Кратко эти отличия можно описать так: Пакет benhcr находится в CRAN (для версии R старше 3.3.2), а development-версия доступна на gitlab, там же есть инструкции по установке. Использование benchr в нашем примере будет выглядеть так (всё, что нужно – заменить функцию microbenchmark на функцию benchmark): Помимо удобного текстового вывода (столбец relative позволяет быстро оценить преимущество самого быстрого решения), мы можем воспользоваться различными визуализациями. Кстати, если у вас установлен пакет ggplot2, он будет использован для отрисовки графиков. Если вы по каким-то причинам не хотите использовать ggplot2, такое поведение может быть изменено при помощи опций пакета benchr. Сейчас поддерживаются три типичных визуализации результатов замера: dotplot, boxplot и violin plot. Итак, по результатам замеров можно сформулировать следующие выводы. А теперь самое важное, что я хотел бы донести этой статьёй. Если знать, как устроен язык R и какую философию он исповедует; если помнить про естественную векторизацию и мощный набор функций базового R; наконец, если вооружиться современными средствами замера времени исполнения, то даже самая тяжелая задача может оказаться вполне подъёмной, и вам не придётся проводить долгие минуты и даже часы в ожидании завершения работы вашего кода. Небольшой disclaimer: пакет benchr задуман и реализован Артёмом Клевцовым (@artemklevtsov) с добавлениями и улучшениями от меня (Антон Антонов, @tonytonov) и Филиппа Управителева (@konhis). Мы будем рады баг репортам и предложениям по функциональности. Задачи, подобные этой, легли в основу курса «Основы программирования на R» на платформе Stepik.org. Курс открыт без дедлайнов (его можно проходить в свободном темпе) и включен в программу профессиональной переподготовки «Анализ данных» (СПбАУ РАН, Институт биоинформатики). Если вы находитесь в Санкт-Петербурге, то будем рады видеть вас на одной из регулярных встреч SPb R user group (VK, meetup). Материалы и дополнительное чтение по теме: Quant