4.4 Rendimiento en R

“We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%. A good programmer will not be lulled into complacency by such reasoning, he will be wise to look carefully at the critical code; but only after that code has been identified.” -Donald Knuth

Diseña primero, luego optimiza. La optimización del código es un proceso iterativo:

  1. Encuentra el cuello de botella más importante.
  2. Intenta eliminarlo (no siempre se puede).
  3. Repite hasta que tu código sea lo suficientemente rápido.

Diagnosticar

Una vez que tienes código que se puede leer y funciona, el perfilamiento (profiling) del código es un método sistemático que nos permite conocer cuanto tiempo se esta usando en diferentes partes del programa.

Comenzaremos con la función system.time (no es perfilamiento aún), esta calcula el tiempo en segundos que toma ejecutar una expresión (si hay un error, regresa el tiempo hasta que ocurre el error):

  • user time: Tiempo usado por el CPU(s) para evaluar esta expresión, tiempo que experimenta la computadora.

  • elapsed time: tiempo en el reloj, tiempo que experimenta la persona.

Notemos que el tiempo de usuario (user) puede ser menor al tiempo transcurrido (elapsed),

o al revés:

Comparemos la velocidad de dplyr con funciones que se encuentran en R estándar y plyr.

La función system.time supone que sabes donde buscar, es decir, que sabes que expresiones debes evaluar, una función que puede ser más útil cuando uno desconoce cuál es la función que alenta un programa es profvis() del paquete con el mismo nombre.

profvis() utiliza a su vez la función Rprof() de R base, este es un perfilador de muestreo que registra cambios en la pila de funciones, funciona tomando muestras a intervalos regulares y tabula cuánto tiempo se lleva en cada función.

Estrategias para mejorar desempeño

Algunas estrategias para mejorar desempeño:

  1. Utilizar apropiadamente funciones de R, o funciones de paquetes que muchas veces están mejor escritas de lo que nosotros podríamos hacer.
  2. Hacer lo menos posible.
  3. Usar funciones vectorizadas en R (casi siempre). No hacer crecer objetos (es preferible definir su tamaño antes de operar en ellos).
  4. Paralelizar.
  5. La más simple y muchas veces la más barata: conseguir una máquina más grande (por ejemplo Amazon web services).

A continuación revisamos y ejemplificamos los puntos anteriores, los ejemplos de código se tomaron del taller EfficientR, impartido por Martin Morgan.

Utilizar apropiadamente funciones de R

Si el cuello de botella es la función de un paquete vale la pena buscar alternativas, CRAN task views es un buen lugar para buscar.

Hacer lo menos posible

Utiliza funciones más específicas, por ejemplo:

  • rowSums(), colSums(), rowMeans() y colMeans() son más rápidas que las invocaciones equivalentes de apply().

  • Si quieres checar si un vector contiene un valor any(x == 10) es más veloz que 10 %in% x, esto es porque examinar igualdad es más sencillo que examinar inclusión en un conjunto.
    Este conocimiento requiere que conozcas alternativas, para ello debes construir tu vocabulario, puedes comenzar por lo básico e ir incrementando conforme lees código.
    Otro caso es cuando las funciones son más rápidas cunado les das más información del problema, por ejemplo:

  • read.csv(), especificar las clases de las columnas con colClasses.
  • factor() especifica los niveles con el argumento levels.

Usar funciones vectorizadas en R

Es común escuchar que en R vectorizar es conveniente, el enfoque vectorizado va más allá que evitar ciclos for:

  • Pensar en objetos, en lugar de enfocarse en las componentes de un vector, se piensa únicamente en el vector completo.

  • Los ciclos en las funciones vectorizadas de R están escritos en C, lo que los hace más veloces.

Las funciones vectorizadas programadas en R pueden mejorar la interfaz de una función pero no necesariamente mejorar el desempeño. Usar vectorización para desempeño implica encontrar funciones de R implementadas en C.

Al igual que en el punto anterior, vectorizar requiere encontrar las funciones apropiadas, algunos ejemplos incluyen: _rowSums(), colSums(), rowMeans() y colMeans().

Ejemplo: iteración (for, lapply(), sapply(), vapply(), mapply(), apply(), …) en un vector de n elementos llama a R base n veces

Utilizamos el paquete microbenchmark para medir tiempos varias veces.

Evitar copias

Otro aspecto importante es que generalmente conviene asignar objetos en lugar de hacerlos crecer (es más eficiente asignar toda la memoria necesaria antes del cálculo que asignarla sucesivamente). Esto es porque cuando se usan instrucciones para crear un objeto más grande (e.g. append(), cbind(), c(), rbind()) R debe primero asignar espacio a un nuevo objeto y luego copiar al nuevo lugar. Para leer más sobre esto Burns (2015) es una buena referencia.

Ejemplo: crecer un vector puede causar que R copie de manera repetida el vector chico en el nuevo vector, aumentando el tiempo de ejecución.

Solución: crear vector de tamaño final y llenarlo con valores. Las funciones como lapply() y map hacen esto de manera automática y son más sencillas que los ciclos for.

Un caso común donde se hacen copias sin necesidad es al trabajar con data.frames.

Ejemplo: actualizar un data.frame copia el data.frame completo.

Solución: operar en vectores y actualiza el data.frame al final.

Paralelizar

Paralelizar usa varios cores para trabajar de manera simultánea en varias secciones de un problema, no reduce el tiempo computacional pero incrementa el tiempo del usuario pues aprovecha los recursos. Como referencia está [Parallel Computing for Data Science] de Norm Matloff.

Referencias

Burns, P. 2015. The R Inferno. The American Statistician. Vol. 4. 69.