-
Notifications
You must be signed in to change notification settings - Fork 1
/
Perf-measure.qmd
260 lines (174 loc) · 16 KB
/
Perf-measure.qmd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
# Medición de desempeño {#sec-perf-measure}
\index{performance!measuring}
```{r include = FALSE}
source("common.R")
num <- function(x) format(round(x), big.mark = ",", scientific = FALSE)
ns <- function(x) paste0(num(round(unclass(x) * 1e9, -1)), " ns")
```
## Introducción
> Los programadores pierden enormes cantidades de tiempo pensando o preocupándose por la velocidad de las partes no críticas de sus programas, y estos intentos de eficiencia en realidad tienen un fuerte impacto negativo cuando se consideran la depuración y el mantenimiento.
>
> --- Donald Knuth
Antes de que pueda hacer que su código sea más rápido, primero debe averiguar qué lo hace lento. Esto suena fácil, pero no lo es. Incluso los programadores experimentados tienen dificultades para identificar cuellos de botella en su código. Entonces, en lugar de confiar en su intuición, debe **perfilar** su código: mida el tiempo de ejecución de cada línea de código usando entradas realistas.
Una vez que haya identificado los cuellos de botella, deberá experimentar cuidadosamente con alternativas para encontrar un código más rápido que aún sea equivalente. En el @sec-perf-improve aprenderá un montón de formas de acelerar el código, pero primero necesita aprender cómo **microbenchmark** para que pueda medir con precisión la diferencia en el rendimiento.
### Estructura {.unnumbered}
- La @sec-profiling le muestra cómo usar las herramientas de creación de perfiles para profundizar exactamente en lo que hace que el código sea lento.
- La @sec-microbenchmarking muestra cómo usar microbenchmarking para explorar implementaciones alternativas y descubrir exactamente cuál es la más rápida.
### Requisitos {.unnumbered}
Usaremos [profvis](https://rstudio.github.io/profvis/) para creación de perfiles y [bench](https://bench.r-lib.org/) para microbenchmarking.
```{r setup}
library(profvis)
library(bench)
```
## Perfiles {#sec-profiling}
\index{profiling} \index{RProf()}
En todos los lenguajes de programación, la herramienta principal utilizada para comprender el rendimiento del código es el generador de perfiles. Hay varios tipos diferentes de perfiladores, pero R usa un tipo bastante simple llamado perfilador estadístico o de muestreo. Un generador de perfiles de muestreo detiene la ejecución del código cada pocos milisegundos y registra la pila de llamadas (es decir, qué función se está ejecutando actualmente y la función que llamó a la función, etc.). Por ejemplo, considere `f()`, a continuación:
```{r}
f <- function() {
pause(0.1)
g()
h()
}
g <- function() {
pause(0.1)
h()
}
h <- function() {
pause(0.1)
}
```
(Utilizo `profvis::pause()` en lugar de `Sys.sleep()` porque `Sys.sleep()` no aparece en las salidas de creación de perfiles porque, por lo que R puede decir, no consume tiempo de cálculo .) \index{pausa()}
Si perfiláramos la ejecución de `f()`, deteniendo la ejecución del código cada 0.1 s, veríamos un perfil como este:
```{r, eval = FALSE}
"pause" "f"
"pause" "g" "f"
"pause" "h" "g" "f"
"pause" "h" "f"
```
Cada línea representa un "tick" del generador de perfiles (0,1 s en este caso), y las llamadas a funciones se registran de derecha a izquierda: la primera línea muestra `f()` llamando a `pause()`. Muestra que el código gasta 0.1 s ejecutando `f()`, luego 0.2 s ejecutando `g()`, luego 0.1 s ejecutando `h()`.
Si realmente perfilamos `f()`, usando `utils::Rprof()` como en el código de abajo, es poco probable que obtengamos un resultado tan claro.
```{r, eval = FALSE}
tmp <- tempfile()
Rprof(tmp, interval = 0.1)
f()
Rprof(NULL)
writeLines(readLines(tmp))
#> sample.interval=100000
#> "pause" "g" "f"
#> "pause" "h" "g" "f"
#> "pause" "h" "f"
```
Esto se debe a que todos los perfiladores deben hacer un equilibrio fundamental entre precisión y rendimiento. El compromiso que se obtiene al usar un generador de perfiles de muestreo solo tiene un impacto mínimo en el rendimiento, pero es fundamentalmente estocástico porque existe cierta variabilidad tanto en la precisión del temporizador como en el tiempo que toma cada operación. Eso significa que cada vez que hagas un perfil obtendrás una respuesta ligeramente diferente. Afortunadamente, la variabilidad afecta más a las funciones que tardan muy poco en ejecutarse, que también son las funciones de menor interés.
### Visualización de perfiles
\index{profvis()}
La resolución de creación de perfiles predeterminada es bastante pequeña, por lo que si su función tarda incluso unos segundos, generará cientos de muestras. Eso crece rápidamente más allá de nuestra capacidad de mirar directamente, así que en lugar de usar `utils::Rprof()` usaremos el paquete profvis para visualizar agregados. profvis también conecta los datos de creación de perfiles con el código fuente subyacente, lo que facilita la creación de un modelo mental de lo que necesita cambiar. Si encuentra que profvis no ayuda con su código, puede probar una de las otras opciones como `utils::summaryRprof()` o el paquete proftools [@proftools].
Hay dos formas de usar profvis:
- Desde el menú Profile en RStudio.
- Con `profvis::profvis()`. Recomiendo almacenar su código en un archivo separado y `source()`; esto garantizará que obtenga la mejor conexión entre los datos de creación de perfiles y el código fuente.
```{r, eval = FALSE}
source("profiling-example.R")
profvis(f())
```
Una vez completada la creación de perfiles, profvis abrirá un documento HTML interactivo que le permitirá explorar los resultados. Hay dos paneles, como se muestra en la @fig-flamegraph.
```{r fig-flamegraph, echo = FALSE, out.width = "100%", fig.cap = "Salida profvis que muestra la fuente en la parte superior y el gráfico de llamas a continuación."}
knitr::include_graphics("screenshots/performance/flamegraph.png")
```
El panel superior muestra el código fuente, superpuesto con gráficos de barras para memoria y tiempo de ejecución para cada línea de código. Aquí me centraré en el tiempo y volveremos a la memoria en breve. Esta pantalla le brinda una buena idea general de los cuellos de botella, pero no siempre lo ayuda a identificar con precisión la causa. Aquí, por ejemplo, puedes ver que `h()` tarda 150 ms, el doble que `g()`; eso no se debe a que la función sea más lenta, sino a que se llama con el doble de frecuencia.
El panel inferior muestra un **gráfico de llamas** que muestra la pila de llamadas completa. Esto le permite ver la secuencia completa de llamadas que conducen a cada función, lo que le permite ver que `h()` se llama desde dos lugares diferentes. En esta pantalla, puede pasar el mouse sobre las llamadas individuales para obtener más información y ver la línea correspondiente del código fuente, como en la @fig-perf-info.
```{r fig-perf-info, echo = FALSE, out.width = "100%", fig.cap = "Al pasar el cursor sobre una llamada en el gráfico de llamas, se resalta la línea de código correspondiente y se muestra información adicional sobre el rendimiento."}
knitr::include_graphics("screenshots/performance/info.png")
```
Alternativamente, puede usar la **pestaña de datos**, @fig-perf-tree le permite sumergirse de forma interactiva en el árbol de datos de rendimiento. Esta es básicamente la misma pantalla que el gráfico de llama (girado 90 grados), pero es más útil cuando tiene pilas de llamadas muy grandes o muy anidadas porque puede optar por hacer zoom de forma interactiva solo en los componentes seleccionados.
```{r fig-perf-tree, echo = FALSE, out.width = "100%", fig.cap = "The data gives an interactive tree that allows you to selectively zoom into key components"}
knitr::include_graphics("screenshots/performance/tree.png")
```
### Perfilado de memoria {#sec-memory-profiling}
\index{profiling!memory} \index{garbage collector!performance} \index{memory usage}
Hay una entrada especial en el gráfico de llamas que no corresponde a su código: `<GC>`, que indica que el recolector de basura se está ejecutando. Si `<GC>` toma mucho tiempo, generalmente es una indicación de que está creando muchos objetos de corta duración. Por ejemplo, tome este pequeño fragmento de código:
```{r}
x <- integer()
for (i in 1:1e4) {
x <- c(x, i)
}
```
Si lo perfilas, verás que la mayor parte del tiempo lo pasa en el recolector de basura, @fig-perf-memory.
```{r fig-perf-memory, echo = FALSE, out.width = "100%", fig.cap = "Perfilar un bucle que modifica una variable existente revela que la mayor parte del tiempo se pasa en el recolector de basura(<GC>)."}
knitr::include_graphics("screenshots/performance/memory.png")
```
Cuando vea que el recolector de elementos no utilizados ocupa mucho tiempo en su propio código, a menudo puede descubrir el origen del problema observando la columna de memoria: verá una línea donde se asignan grandes cantidades de memoria (el barra de la derecha) y liberada (la barra de la izquierda). Aquí surge el problema debido a la copia al modificar (@sec-copy-on-modify): cada iteración del bucle crea otra copia de `x`. Aprenderá estrategias para resolver este tipo de problema en la @sec-avoid-copies.
### Limitaciones
\index{profiling!limitations}
Hay algunas otras limitaciones para la creación de perfiles:
- La generación de perfiles no se extiende al código C. Puede ver si su código R llama al código C/C++ pero no qué funciones se llaman dentro de su código C/C++. Desafortunadamente, las herramientas para generar perfiles de código compilado están más allá del alcance de este libro; Comience mirando <https://github.com/r-prof/jointprof>.
- Si está haciendo mucha programación funcional con funciones anónimas, puede ser difícil averiguar exactamente qué función se está llamando. La forma más fácil de evitar esto es nombrar sus funciones.
- La evaluación perezosa significa que los argumentos a menudo se evalúan dentro de otra función, y esto complica la pila de llamadas (@sec-lazy-call-stack). Desafortunadamente, el generador de perfiles de R no almacena suficiente información para desenredar la evaluación perezosa, por lo que en el siguiente código, la generación de perfiles haría que pareciera que `i()` fue llamado por `j()` porque el argumento no se evalúa hasta que lo necesita `j()`.
```{r, eval = FALSE}
i <- function() {
pause(0.1)
10
}
j <- function(x) {
x + 10
}
j(i())
```
Si esto es confuso, use `force()` (@sec-forcing-evaluation) para forzar que el cálculo ocurra antes.
### Ejercicios
<!-- La explicación de `torture = VERDADERO` fue eliminada en https://github.com/hadley/adv-r/commit/ea63f1e48fb523c013fb3df1860b7e0c227e1512 -->
1. Perfila la siguiente función con `torture = 10`. ¿Qué es sorprendente? Lea el código fuente de `rm()` para averiguar qué está pasando.
```{r}
f <- function(n = 1e5) {
x <- rep(1, n)
rm(x)
}
```
## Microbenchmark {#sec-microbenchmarking}
\index{microbenchmarking|see {benchmarking}} \index{benchmarking}
Un **microbenchmark** es una medida del rendimiento de un fragmento de código muy pequeño, algo que puede tardar milisegundos (ms), microsegundos (µs) o nanosegundos (ns) en ejecutarse. Los microbenchmarks son útiles para comparar pequeños fragmentos de código para tareas específicas. Tenga mucho cuidado al generalizar los resultados de los micropuntos de referencia al código real: las diferencias observadas en los micropuntos de referencia normalmente estarán dominadas por efectos de orden superior en el código real; una comprensión profunda de la física subatómica no es muy útil al hornear.
Una gran herramienta para microbenchmarking en R es el paquete de banco [@bench]. El paquete de banco utiliza un temporizador de alta precisión, lo que permite comparar operaciones que solo toman una pequeña cantidad de tiempo. Por ejemplo, el siguiente código compara la velocidad de dos enfoques para calcular una raíz cuadrada.
```{r bench-sqrt}
x <- runif(100)
(lb <- bench::mark(
sqrt(x),
x ^ 0.5
))
```
De forma predeterminada, `bench::mark()` ejecuta cada expresión al menos una vez (`min_iterations = 1`) y, como máximo, las veces necesarias para tardar 0,5 s (`min_time = 0,5`). Comprueba que cada ejecución devuelve el mismo valor, que suele ser lo que desea microbenchmarking; si desea comparar la velocidad de las expresiones que devuelven valores diferentes, configure `check = FALSE`.
### Resultados de `bench::mark()`
\index{mark()}
`bench::mark()` devuelve los resultados como un tibble, con una fila para cada expresión de entrada y las siguientes columnas:
- `min`, `mean`, `median`, `max`, y `itr/sec` resume el tiempo que tarda la expresión. Concéntrese en el mínimo (el mejor tiempo de ejecución posible) y la mediana (el tiempo típico). En este ejemplo, puede ver que usar la función `sqrt()` de propósito especial es más rápido que el operador de exponenciación general.
Puedes visualizar la distribución de los tiempos individuales con `plot()`:
```{r}
plot(lb)
```
La distribución tiende a ser muy sesgada hacia la derecha (¡tenga en cuenta que el eje x ya está en una escala logarítmica!), razón por la cual debe evitar comparar medias. También verá a menudo multimodalidad porque su computadora está ejecutando algo más en segundo plano.
- `mem_alloc` te dice la cantidad de memoria asignada por la primera ejecución, y `n_gc()` te dice el número total de recolecciones de basura en todas las ejecuciones. Estos son útiles para evaluar el uso de memoria de la expresión.
- `n_itr` y `total_time` le dice cuántas veces se evaluó la expresión y cuánto tiempo tomó en total. `n_itr` siempre será mayor que el parámetro `min_iteration`, y `total_time` siempre será mayor que el parámetro `min_time`.
- `result`, `memory`, `time`, y `gc` son columnas de lista que almacenan los datos subyacentes sin procesar.
Debido a que el resultado es un tipo especial de tibble, puede usar `[` para seleccionar solo las columnas más importantes. Lo haré con frecuencia en el próximo capítulo.
```{r}
lb[c("expression", "min", "median", "itr/sec", "n_gc")]
```
### Interpretación de resultados
```{r, dependson = "bench-sqrt", include = FALSE}
sqrt_x <- unclass(round(lb$min[[1]], 8))
```
Al igual que con todos los micropuntos de referencia, preste mucha atención a las unidades: aquí, cada cálculo toma alrededor de `r ns(sqrt_x)`, `r num(sqrt_x * 1e9)` mil millonésimas de segundo. Para ayudar a calibrar el impacto de un micropunto de referencia en el tiempo de ejecución, es útil pensar cuántas veces debe ejecutarse una función antes de que tarde un segundo. Si un microbenchmark toma:
- 1 ms, entonces mil llamadas toman un segundo.
- 1 µs, luego un millón de llamadas toman un segundo.
- 1 ns, luego mil millones de llamadas toman un segundo.
La función `sqrt()` toma aproximadamente `r ns(sqrt_x)`, o `r format(sqrt_x * 1e6)` µs, para calcular las raíces cuadradas de 100 números. Eso significa que si repitió la operación un millón de veces, tomaría `r format(sqrt_x * 1e6)` s y, por lo tanto, es poco probable que cambiar la forma en que calcula la raíz cuadrada afecte significativamente el código real. Esta es la razón por la que debe tener cuidado al generalizar los resultados de microbenchmarking.
### Ejercicios
1. En lugar de usar `bench::mark()`, podrías usar la función integrada `system.time()`. Pero `system.time()` es mucho menos preciso, por lo que deberá repetir cada operación muchas veces con un ciclo y luego dividir para encontrar el tiempo promedio de cada operación, como en el código a continuación.
```{r, eval = FALSE}
n <- 1e6
system.time(for (i in 1:n) sqrt(x)) / n
system.time(for (i in 1:n) x ^ 0.5) / n
```
¿Cómo se comparan las estimaciones de `system.time()` con las de `bench::mark()`? ¿Por qué son diferentes?
2. Aquí hay otras dos formas de calcular la raíz cuadrada de un vector. ¿Cuál crees que será más rápido? ¿Cuál será más lento? Utilice microbenchmarking para probar sus respuestas.
```{r, eval = FALSE}
x ^ (1 / 2)
exp(log(x) / 2)
```