Depuración y Optimización de Programas Alex Sánchez 12 de marzo de 2008 Índice 1. Introducción 1 2. Depuración y control de errores 2.1. Trazado de la ejecución. Pila de llamadas 2.2. Depuración en R . . . . . . . . . . . . . . 2.2.1. Iniciar el navegador con el error . . 2.2.2. La función trace . . . . . . . . . . 2.3. Manejo de excepciones . . . . . . . . . . . 2.4. Extensiones al sistema básico . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 2 4 5 6 6 8 3. Programación eficiente 3.1. Estilos de programación . . . . . . . . . . . . . . . . 3.2. Programación vectorial y bucles . . . . . . . . . . . . 3.2.1. Uso razonable de los bucles . . . . . . . . . . 3.2.2. Programación vectorial: apply() y compañı́a 3.3. Herramientas para el análisis de la eficiencia . . . . . 3.3.1. Medida del tiempo de ejecución . . . . . . . . 3.3.2. Uso de la memoria . . . . . . . . . . . . . . . 3.4. Analisis del tiempo de ejecución o Profiling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8 9 9 9 10 11 11 11 12 1. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . Introducción Como norma general deberı́amos intentar escribir código correcto, limpio y eficiente desde la primera versión de un programa. Para ello es conveniente estructurar y encapsular el código, ya sea mediante la modularización, es decir funciones que llaman a otras funciones, o bien recurriendo a la OOP. Sin embargo a pesar de todo nuestro código puede contener errores o estar realizado de forma ineficiente. En este documento se consideran tres aspectos relacionados con la mejora de los programas escritos en R . 1 Es muy probabe que nuestro código contenga errores. El proceso de encontrarlos y eliminarlos es la depuración. Aún cuando no contenga errores es probable que haya maneras de utilizar R de manera adecuada para que un programa se ejecute de forma más eficiente. Para ello es de gran utilidad disponer de mecanismos para determinar los ‘cuellos de botella” en los que el código puede pasar mucho -demasiado- tiempo ejecutándose. El proceso de saber cuánto tiempo demora la ejecución de un programa en cada instrucción recibe el nombre de Profiling, y aunque no dice nada sobre cómo mejorar el código si que indica donde es conveniente invertir mayores esfuerzos optimizandolo. A veces una forma sencilla de optimizar es combinar el código R con codigo compilado en otro lenguaje, por ejemplo en lenguaje C. 2. Depuración y control de errores Si un programa contiene errores no es que no sea eficiente, es que no funciona, o lo que es peor, no hace lo que se espera de él. En esta sección nos centraremos en la detección de errores que producen la interrupción de la ejecución, aunque los métodos expuestos pueden también ser utilizados para la revisión del código y la detección de errores lógicos. La detección y eliminación de los errores en un programa recibe el nombre genérico de depuración. La depuración de un programa suele realizarse en dos pasos: En primer lugar hay que localizar donde el sistema (R en este caso) ha detectado el error. Desde este punto suele ser preciso retroceder hasta encontrar lo que ha producido el problema. 2.1. Trazado de la ejecución. Pila de llamadas Idealmente desearı́amos encontrar que llamada concreta produce un error, pero esto no siempre resulta fácil o posible. A menudo el problema no lo genera una lı́nea sino una manipulación anterior de los datos que hacen que esta linea determinada falle en esta ejecución determinada. Ası́ pues lo que resulta útil es saber que funciones estan activas en el momento de producirse el error, es decir que funciones estan siendo evaluadas. La lista de las funciones evaluadas en un momento dado recibe el nombre de pila de llamada o call stack. Una vez se ha producido un error la función traceback() permite localizar donde ha sucedido. Esta función muestra la pila de llamadas que estaba activa en el momento del error con lo que la última llamada indica en que función se encontraba esta pila cuando se producjo el error. 2 > + + + > + + > > foo <- function(x) { print(1) bar(2) } bar <- function(x) { x + una.variable.que.no.existe } foo(2) traceback() Ciertos errores, por ejemplo una raı́z cuadrada de un numero negativo o una división por cero, no son necesariamente tratados como tales, y tan sólo se produce un mensaje de advertencia (o ni eso segun el valor de options(”warn”) > > + + > + + + + + > + + > + + > options(warn = 1) discri <- function(a, b, ce) { return(b^2 - 4 * a * ce) } numer <- function(b, disc) { if (disc == 0) num <- -b else num <- c(-b - sqrt(disc), -b + sqrt(disc)) return(num) } soluc <- function(a, numereq) { return(numereq/(2 * a)) } equac2G <- function(a, b, ce) { soluc(a, numereq = numer(b, disc = discri(a, b, ce))) } equac2G(0, 1, 1) [1] -Inf NaN > equac2G(1, 2, 1) [1] -1 > equac2G(3, 2, 1) [1] NaN NaN Este comportamiento puede modificarse convirtiendo los warnings en errores lo que permitirá utilizar traceback() para localizar su origen. Para ello debemos cambiar las opciones de aviso haciendo options(’warn’=2). Si una vez hecho se repite la llamada a equac2G (3,2,1) se producirá un error y la instrucción traceback() permitirá localizarlo. Observese que en este caso se recupera también la redefinición de la opción “warnings”. 3 > options("warn"=2) > equac2G (3,2,1) Error en sqrt(disc) : (convertido del aviso) Se han producido NaNs > traceback() 7: doWithOneRestart(return(expr), restart) 6: withOneRestart(expr, restarts[[1]]) 5: withRestarts({ .Internal(.signalCondition(simpleWarning(msg, call), msg, call)) .Internal(.dfltWarn(msg, call)) }, muffleWarning = function() NULL) 4: .signalSimpleWarning("Se han producido NaNs", quote(sqrt(disc))) 3: numer(b, disc = discri(a, b, ce)) 2: soluc(a, numereq = numer(b, disc = discri(a, b, ce))) 1: equac2G(3, 2, 1) > options("warn"=1) A veces el error se produce porque de una versión a otra una clase o una función cambian su comportamiento. Aunque traceback() permite localizar la llamada que produjo el error puede no ser obvio a que se debe éste. En éstos casos puede interesarnos reseguir la ejecución paso a paso, o bien retroceder hacia funciones anteriores. 2.2. Depuración en R La depuración en R se basa principalmente en la función browser. Ésta inicia una ejecución paso a paso y puede invocarse desde dentro de cualquier función. Es decir si sabemos en que punto puede producirse el error podemos insertar la llamada antes de éste punto. > + + + + > > logit0 <- function(p) { quoc <- p/(1 - p) browser() logQuoc <- log(quoc) } logit0(1) logit0(-1) La forma más habitual de utilizar la ejecución paso a paso es mediante la instrucción debug que se aplica a cada funcion que deseamos seguir paso a paso. > debug(logit0) > logit0(2) Cuando se invoque foo entraremos en una ejecución paso a paso que además nos mostrará cada lı́nea antes de ejecutarla. Desde dentro del depurador podemos 4 Visualizar cualquier variable global (o si está disponible) local en la función. Ejecutar instrucciones de R Ejecutar instrucciones especı́ficas del depurador (haga help(debug) para más explicaciones. Las instrucciones de depuracion que muestra la ayuda de R son: n (or just return). Advance to the next step. c continue to the end of the current context: e.g. to the end of the loop if within a loop or to the end of the function. 'cont' synonym for 'c'. 'where' print a stack trace of all active function calls. 'Q' exit the browser and the current evaluation and return to the top-level prompt. (Leading and trailing whitespace is ignored, except for return). ' ' ' ' Dos puntos importantes: Para salir del depurador debe escribirse ’Q’ Para que ya no se invoque al depurador debe invertirse la llamada con undebug(). 2.2.1. Iniciar el navegador con el error Si se activa la opción de error ’recover’ se entrará en el navegador (’browser’) cuando se produzca el error sin necesidad de insertar llamadas puntuales a browse. Observese que al entrar en el navegador se muestra la lista de llamadas. Al seleccionar una de ellas las variables locales a esta funciçonm estan disponibles para inspeccionarlas escribiendo su nombre. ATENCIÓN: No debe confundirse el navegador con el depurador. El primero permite acceder a las variables locales de cada llamada en la lista. El segundo permite la ejecución paso a paso. > options(error = recover, warn = 2) > equac2G(3, 2, 1) > options(error = NULL, warn = 1) Si en vez de ’recover’ se utiliza la opción ’dump.frames’ los marcos de llamada quedaran almacenados en un objeto que podremos revisar con la opción debugger(). > options(error = dump.frames, warn = 2) > equac2G(3, 2, 1) > options(error = NULL, warn = 1) 5 2.2.2. La función trace La función trace tiene una funcionalidad similar a la del navegador con la diferencia que imprime una linea cuando el proceso entra en la función a seguir. > > > > options(warn = 2) trace(numer) for (i in 1:3) equac2G(i, 2, 1) options(warn = 1) 2.3. Manejo de excepciones En ciertas ocasiones deseamos que el control del error pueda realizarse desde dentro de la función misma. Esto puede parecer un problema en tanto que si se produce un error la ejecución se detiene. El sistema de Control de excepciones permite capturar el error sin que se interrumpa el programa. Para ello es preciso que el código incluya la llamada a la función que puede producir el error dentro de la instrucción try. Ésta retornará el valor de la función que contiene, si no se ha poducido ningún error y un objeto de tipo ’error’ cuando suceda éste. La ejecución no se detiene pero la detección de si se ha producido o no un error corre a cargo del ususario. > > + + + + + + + + + + > options(warn = 2) numerTry <- function(b, disc) { if (disc == 0) num <- -b else { raiz <- try(sqrt(disc), silent = TRUE) if (class(raiz) == "try-error") num <- "Error: Discriminante menor que cero" else num <- c(-b - raiz, -b + raiz) } return(num) } numerTry(1, 2) [1] -2.4142136 0.4142136 > numerTry(1, -1) [1] "Error: Discriminante menor que cero" > options(warn = 1) El siguiente ejemplo nuestra como la función testBiocConnection intenta determinar si se puede acceder al repositorio de Bioconductor en internet, y si ello no es posible captura el error y cierra la conexión. 6 > require(Biobase) > testBioCConnection function () { curNetOpt <- getOption("internet.info") on.exit(options(internet.info = curNetOpt), add = TRUE) options(internet.info = 3) http <- as.logical(capabilities(what = "http/ftp")) if (http == FALSE) return(FALSE) bioCoption <- getOption("BIOC") if (is.null(bioCoption)) bioCoption <- "http://www.bioconductor.org" biocURL <- url(paste(bioCoption, "/main.html", sep = "")) options(show.error.messages = FALSE) test <- try(readLines(biocURL)[1]) options(show.error.messages = TRUE) if (inherits(test, "try-error")) return(FALSE) else close(biocURL) return(TRUE) } <environment: namespace:Biobase> En vez de try se puede utilizar tryCatch que permite gestionar por separado las acciones a efectuar si se produce un warning o un error. > foo <- function(x) { + if (x < 3) + list() + x + else { + if (x < 10) + warning("ouch") + else 33 + } + } > tryCatch(foo(2), error = function(e) "Un error", warning = function(e) "Un aviso", + finally = print("Si falla no podremos continuar")) [1] "Si falla no podremos continuar" [1] "Un error" > tryCatch(foo(5), error = function(e) "Un error", warning = function(e) "Un aviso", + finally = print("Si falla podremos continuar")) [1] "Si falla podremos continuar" [1] "Un aviso" 7 tryCatch(foo(1), finally= print("Si falla nos quedamos aqui...")) Error en list() + x : argumento no-numérico para operador binario [1] "Si falla nos quedamos aqui..." 2.4. Extensiones al sistema básico Existen diversos paquetes y algunos sitios donde se amplı́a la información y las posibilidades del sistema de depuración de R . Aunque no entraremos en detalle se puede destacar: Algunos documentos populares An Introduction to the Interactive Debugging Tools in R (Roger Peng) Debugging in R (Debugging in R) El paquete debug con extensiones basadas en Tcl/Tk para permitir una depuración “algo más visual”. El uso del paquete se describe en el artı́culo Debugging without (too many) tears. R News, 3(3):29-32 Este paquete contiene funciones de depuración más potentes que las del depurador de R permitiendo colocar puntos de ruptura, saltar lineas o ir a una posición concreta El paquete codeTools contiene funciones que permiten analizar que otras funciones se encuentran en uso, que variables estan asignadas o sin utilizar y utilidades similares. > > > > > > > stopifnot(require(debug)) source(file.path("./RCode/buildGOProf.R")) simpleLLids <- as.character(c(2189, 5575, 5569, 11)) mtrace(GOTermsList) simpleGOlist <- GOTermsList(simpleLLids, na.rm = F) mtrace.off() stopifnot(require(codetools)) 3. Programación eficiente R es un lenguaje interpretado, lo que suele ser sinónimo de ineficiencia. Esto debe de matizarse porque aunque es cierto que el mismo código se ejecuta más rápido compilado que interpretado, ello no sognifica que cualquier alternativa “compilable” vaya a hacerlo mejor. A pesar de lo anterior, R se puede considerar poco efiiente en muchos aspectos por lo que puede valer la pena intentar aumentar la eficiencia de nuestro código. Lieges (2007) da algunas normas generales para conseguir unos programas más eficientes: 8 El lector deberı́a considerar la programación vectorial como la ley El codigo existente y optimizado suele basarse en rutinas FORTRAN o C ya implementadas. “Mejor no reinventar la rueda”. Las partes crı́ticas del código pueden escribirse en C o FORTRAN y compilarlas lincándolas a nuestro código a través de bibliotecas. Si se puede hay que utilizar un “haiga” (lo más potente que “haiga”): Más memória, más velocidad, más ordenadores, más procesadores ayudaran a realizar los cálculos más rápidamente. 3.1. Estilos de programación Una forma de programar adecuada es el priomer paso hacia un código eficiente. Lieges (2007) da algunos consejos de buen estilo que enumeramos a continuación: Generalidad Una función debe ser lo más general posible. Comprensibilidad Hay que documentar al máximo el código realizado. Legibilidad El código demasiado compacto es difı́cil de entender. Casi tanto como el demasiado poco compacto. Estética El código debe ser claro de leer con indentaciones, lineas en blanco y separaciones apropiadas. Eficiencia Cuando la ejecución dependa de la velocidad i/o de la memoria debe de optimizarse, siempre que sea posible, el código que se cree. 3.2. Programación vectorial y bucles En general la programación vectorial es mejor que los bucles. Esto no significa que un bucle sea siempre malo, pero conviene atender algunas reglas. 3.2.1. Uso razonable de los bucles A veces es conveniente utilizar un bucle. Por ejemplo ciertas operaciones vectoriales pueden saturar la memoria, bloqueando o ralentizando el sistema. Una versión basada en bucles que descomponga el cálculo en partes y utilice siempre la misma, y menor, fracción de memoria puede ser más adecuada. Ası́ por ejemplo los bucles pueden resultar más adecuados para cálculos recursivos que se programen de manera iterativa. Si se va a utilizar un bucle puede ser conveniente tener en cuenta los siguientes puntos Es conveniente inicializar un objeto con las dimensiones que se requeriran para toda la ejecución Es decir es mucho más rápido crear el objeto y llenarlo, que hacerlo crecer a medida que se crea. 9 > > > + + + > foo <- function(x) x n <- 10000 omple1 <- function(n) { a <- NULL for (i in 1:n) a <- c(a, foo(i)) } system.time(omple1(10000)) user 0.21 system elapsed 0.05 0.25 > omple2 <- function(n) { + a <- numeric(n) + for (i in 1:n) a[i] <- foo(i) + } > system.time(omple2(10000)) user 0.12 system elapsed 0.00 0.12 Dentro de un bucle no deben realizarse comprobaciones innecesarias Si un cálculo puede desplazarse fuera del bucle situarl allı́ > calcul1 <- function(n) { + a <- numeric(n) + for (i in 1:n) a[i] <- 2 * n * pi * foo(i) + } > system.time(calcul1(10000)) user 0.15 system elapsed 0.00 0.17 > calcul2 <- function(n) { + a <- numeric(n) + for (i in 1:n) a[i] <- foo(i) + a <- 2 * n * pi * a + } > system.time(calcul2(10000)) user 0.11 3.2.2. system elapsed 0.00 0.11 Programación vectorial: apply() y compañı́a La utilización de funciones de la familia apply suele generar código eficiente, en tanto que se trata de instrucciones vectorizadas. Obviamente no se trata de decir “Con apply ya es eficiente” sinó de aplicarlo correctamente en cada caso. 10 3.3. Herramientas para el análisis de la eficiencia 3.3.1. Medida del tiempo de ejecución Para optimizar una función es preciso poder medir el tiempo que tarda en ejecutarse. Esto puede hacerse con proc.time() y system.time(). La primera decuelve la hora del sistema al invocarla por lo que se puede llamar antes y después de la ejecución. La segunda cuenta directamente el tiempo invertido en el proceso. > + + + > > > > calcul1 <- function(n) { a <- numeric(n) for (i in 1:n) a[i] <- 2 * n * pi * foo(i) } p1 <- proc.time() calcul1(10000) p2 <- proc.time() p2 - p1 user 0.15 system elapsed 0.00 0.15 > system.time(calcul1(10000)) user 0.17 3.3.2. system elapsed 0.00 0.17 Uso de la memoria En R es posible indicar al arrancar como se utilizará la memoria (?Memory proporciona una explicación aceptable). A medida que se trabaja suele ser aconsejable “limpiar los restos” que van quedando en la memoria. Aunque R lo haga de manera automática parece ser conveniente después que se ha eliminado un objeto grande de la memoria. > memUsage <- function() { + print("memory.size reports the current or maximum memory allocation of the malloc func + print("memory.limit reports or increases the limit in force on the total allocation") + print(memory.size()/2^20) + print(memory.limit()/2^20) + gc() + } > memUsage() [1] [1] [1] [1] "memory.size reports the current or maximum memory allocation of the malloc function use "memory.limit reports or increases the limit in force on the total allocation" 0.0002136681 0.003905296 11 used (Mb) gc trigger (Mb) max used (Mb) Ncells 6140959 164.0 9040660 241.5 6910418 184.6 Vcells 6716555 51.3 19593219 149.5 35778869 273.0 3.4. Analisis del tiempo de ejecución o Profiling Las funciones de medida del tiempo tan sólo indican el tiempo transcurrido del principio al final de una ejecución. Si se desea descubrir en que parte del código se gasta más tiempo puede recurrirse al sistema de “perfilado” o profiling. Su uso es muy sencillo. Antes de iniciar el proceso que se quiere analizar se invoca la función Rprof(). A intervalos regulares esta medirá el tiempo transcurrido en cada parte del código. El proceso finaliza con una llamada a summaryRprof() que nos muestra el tiempo transcurrido por el programa en cada parte de éste, lo que puede ayudarnos a descubrir eventuales cuellos de botella. El resultado de summaryRprof() son dos matrices, la primera ordnada por “self.time” y la segunda por el tiempo total. > Rprof() > mad(runif(1e+07)) [1] 0.3705096 > Rprof(NULL) > summaryRprof() $by.self sort.int is.na runif abs any <Anonymous> doTryCatch eval.with.vis evalFunc mad processa Sweave try tryCatch tryCatchList self.time self.pct total.time total.pct 2.34 35.8 3.06 46.8 1.96 30.0 1.96 30.0 0.92 14.1 0.92 14.1 0.66 10.1 0.66 10.1 0.62 9.5 0.62 9.5 0.04 0.6 0.04 0.6 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 12 tryCatchOne median median.default mean sort sort.default 0.00 0.00 0.00 0.00 0.00 0.00 0.0 0.0 0.0 0.0 0.0 0.0 6.54 5.62 4.34 3.06 3.06 3.06 100.0 85.9 66.4 46.8 46.8 46.8 $by.total <Anonymous> doTryCatch eval.with.vis evalFunc mad processa Sweave try tryCatch tryCatchList tryCatchOne median median.default sort.int mean sort sort.default is.na runif abs any total.time total.pct self.time self.pct 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 6.54 100.0 0.00 0.0 5.62 85.9 0.00 0.0 4.34 66.4 0.00 0.0 3.06 46.8 2.34 35.8 3.06 46.8 0.00 0.0 3.06 46.8 0.00 0.0 3.06 46.8 0.00 0.0 1.96 30.0 1.96 30.0 0.92 14.1 0.92 14.1 0.66 10.1 0.66 10.1 0.62 9.5 0.62 9.5 0.04 0.6 0.04 0.6 $sampling.time [1] 6.54 13