¿La familia "*apply" realmente no está vectorizada?

Resuelto David Arenburg asked hace 54 años • 4 respuestas

Por eso estamos acostumbrados a decirle a cada nuevo usuario de R que " applyno está vectorizado, consulte el Patrick Burns R Inferno Circle 4 " que dice (cito):

Un reflejo común es utilizar una función de la familia apply. Esto no es vectorización, es ocultación de bucles . La función de aplicación tiene un bucle for en su definición. La función lapply oculta el bucle, pero los tiempos de ejecución tienden a ser aproximadamente iguales a los de un bucle for explícito.

De hecho, un vistazo rápido al applycódigo fuente revela el bucle:

grep("for", capture.output(getAnywhere("apply")), value = TRUE)
## [1] "        for (i in 1L:d2) {"  "    else for (i in 1L:d2) {"

Bien hasta ahora, pero un vistazo lapplyrevela vapplyuna imagen completamente diferente:

lapply
## function (X, FUN, ...) 
## {
##     FUN <- match.fun(FUN)
##     if (!is.vector(X) || is.object(X)) 
##        X <- as.list(X)
##     .Internal(lapply(X, FUN))
## }
## <bytecode: 0x000000000284b618>
## <environment: namespace:base>

Entonces, aparentemente no hay ningún forbucle R escondido allí, sino que están llamando a una función interna escrita en C.

Una mirada rápida a la madriguera del conejo revela prácticamente la misma imagen.

Además, tomemos colMeanspor ejemplo la función, a la que nunca se le acusó de no estar vectorizada.

colMeans
# function (x, na.rm = FALSE, dims = 1L) 
# {
#   if (is.data.frame(x)) 
#     x <- as.matrix(x)
#   if (!is.array(x) || length(dn <- dim(x)) < 2L) 
#     stop("'x' must be an array of at least two dimensions")
#   if (dims < 1L || dims > length(dn) - 1L) 
#     stop("invalid 'dims'")
#   n <- prod(dn[1L:dims])
#   dn <- dn[-(1L:dims)]
#   z <- if (is.complex(x)) 
#     .Internal(colMeans(Re(x), n, prod(dn), na.rm)) + (0+1i) * 
#     .Internal(colMeans(Im(x), n, prod(dn), na.rm))
#   else .Internal(colMeans(x, n, prod(dn), na.rm))
#   if (length(dn) > 1L) {
#     dim(z) <- dn
#     dimnames(z) <- dimnames(x)[-(1L:dims)]
#   }
#   else names(z) <- dimnames(x)[[dims + 1]]
#   z
# }
# <bytecode: 0x0000000008f89d20>
#   <environment: namespace:base>

¿Eh? También simplemente llama .Internal(colMeans(...y también lo podemos encontrar en la madriguera del conejo . Entonces, ¿en qué se diferencia esto de .Internal(lapply(..?

En realidad, una prueba comparativa rápida revela que sapplysu rendimiento no es peor colMeansy mucho mejor que un forbucle para un gran conjunto de datos.

m <- as.data.frame(matrix(1:1e7, ncol = 1e5))
system.time(colMeans(m))
# user  system elapsed 
# 1.69    0.03    1.73 
system.time(sapply(m, mean))
# user  system elapsed 
# 1.50    0.03    1.60 
system.time(apply(m, 2, mean))
# user  system elapsed 
# 3.84    0.03    3.90 
system.time(for(i in 1:ncol(m)) mean(m[, i]))
# user  system elapsed 
# 13.78    0.01   13.93 

En otras palabras, ¿es correcto decir que lapplyy vapply en realidad están vectorizados (en comparación con applycuál es un forbucle que también llama a lapply) y qué quiso decir realmente Patrick Burns?

David Arenburg avatar Jan 01 '70 08:01 David Arenburg
Aceptado

En primer lugar, en su ejemplo realiza pruebas en un "data.frame" que no es justo para colMeans, applyy "[.data.frame"dado que tienen una sobrecarga:

system.time(as.matrix(m))  #called by `colMeans` and `apply`
#   user  system elapsed 
#   1.03    0.00    1.05
system.time(for(i in 1:ncol(m)) m[, i])  #in the `for` loop
#   user  system elapsed 
#  12.93    0.01   13.07

En una matriz, la imagen es un poco diferente:

mm = as.matrix(m)
system.time(colMeans(mm))
#   user  system elapsed 
#   0.01    0.00    0.01 
system.time(apply(mm, 2, mean))
#   user  system elapsed 
#   1.48    0.03    1.53 
system.time(for(i in 1:ncol(mm)) mean(mm[, i]))
#   user  system elapsed 
#   1.22    0.00    1.21

Con respecto a la parte principal de la pregunta, la principal diferencia entre lapply/ mapply/etc y los bucles R sencillos es dónde se realiza el bucle. Como señala Roland, tanto el bucle C como el R necesitan evaluar una función R en cada iteración, que es la más costosa. Las funciones C realmente rápidas son aquellas que hacen todo en C, entonces, supongo, ¿de esto debería tratarse "vectorizado"?

Un ejemplo donde encontramos la media en cada uno de los elementos de una "lista":

( EDITAR 11 de mayo de 2016 : Creo que el ejemplo de encontrar la "media" no es una buena configuración para las diferencias entre evaluar una función de R de forma iterativa y el código compilado, (1) debido a la particularidad del algoritmo de media de R en "numérico" s sobre un ejemplo simple sum(x) / length(x)y (2) debería tener más sentido probar en "listas" con length(x) >> lengths(x). Por lo tanto, el ejemplo "malo" se mueve al final y se reemplaza por otro).

Como ejemplo sencillo podríamos considerar el hallazgo del opuesto de cada length == 1elemento de una "lista":

En un tmp.carchivo:

#include <R.h>
#define USE_RINTERNALS 
#include <Rinternals.h>
#include <Rdefines.h>

/* call a C function inside another */
double oppC(double x) { return(ISNAN(x) ? NA_REAL : -x); }
SEXP sapply_oppC(SEXP x)
{
    SEXP ans = PROTECT(allocVector(REALSXP, LENGTH(x)));
    for(int i = 0; i < LENGTH(x); i++) 
        REAL(ans)[i] = oppC(REAL(VECTOR_ELT(x, i))[0]);

    UNPROTECT(1);
    return(ans);
}

/* call an R function inside a C function;
 * will be used with 'f' as a closure and as a builtin */    
SEXP sapply_oppR(SEXP x, SEXP f)
{
    SEXP call = PROTECT(allocVector(LANGSXP, 2));
    SETCAR(call, install(CHAR(STRING_ELT(f, 0))));

    SEXP ans = PROTECT(allocVector(REALSXP, LENGTH(x)));     
    for(int i = 0; i < LENGTH(x); i++) { 
        SETCADR(call, VECTOR_ELT(x, i));
        REAL(ans)[i] = REAL(eval(call, R_GlobalEnv))[0];
    }

    UNPROTECT(2);
    return(ans);
}

Y en el lado R:

system("R CMD SHLIB /home/~/tmp.c")
dyn.load("/home/~/tmp.so")

con datos:

set.seed(007)
myls = rep_len(as.list(c(NA, runif(3))), 1e7)

#a closure wrapper of `-`
oppR = function(x) -x

for_oppR = compiler::cmpfun(function(x, f)
{
    f = match.fun(f)  
    ans = numeric(length(x))
    for(i in seq_along(x)) ans[[i]] = f(x[[i]])
    return(ans)
})

Evaluación comparativa:

#call a C function iteratively
system.time({ sapplyC =  .Call("sapply_oppC", myls) }) 
#   user  system elapsed 
#  0.048   0.000   0.047 

#evaluate an R closure iteratively
system.time({ sapplyRC =  .Call("sapply_oppR", myls, "oppR") }) 
#   user  system elapsed 
#  3.348   0.000   3.358 

#evaluate an R builtin iteratively
system.time({ sapplyRCprim =  .Call("sapply_oppR", myls, "-") }) 
#   user  system elapsed 
#  0.652   0.000   0.653 

#loop with a R closure
system.time({ forR = for_oppR(myls, "oppR") })
#   user  system elapsed 
#  4.396   0.000   4.409 

#loop with an R builtin
system.time({ forRprim = for_oppR(myls, "-") })
#   user  system elapsed 
#  1.908   0.000   1.913 

#for reference and testing 
system.time({ sapplyR = unlist(lapply(myls, oppR)) })
#   user  system elapsed 
#  7.080   0.068   7.170 
system.time({ sapplyRprim = unlist(lapply(myls, `-`)) }) 
#   user  system elapsed 
#  3.524   0.064   3.598 

all.equal(sapplyR, sapplyRprim)
#[1] TRUE 
all.equal(sapplyR, sapplyC)
#[1] TRUE
all.equal(sapplyR, sapplyRC)
#[1] TRUE
all.equal(sapplyR, sapplyRCprim)
#[1] TRUE
all.equal(sapplyR, forR)
#[1] TRUE
all.equal(sapplyR, forRprim)
#[1] TRUE

(Sigue el ejemplo original de hallazgo de la media):

#all computations in C
all_C = inline::cfunction(sig = c(R_ls = "list"), body = '
    SEXP tmp, ans;
    PROTECT(ans = allocVector(REALSXP, LENGTH(R_ls)));

    double *ptmp, *pans = REAL(ans);

    for(int i = 0; i < LENGTH(R_ls); i++) {
        pans[i] = 0.0;

        PROTECT(tmp = coerceVector(VECTOR_ELT(R_ls, i), REALSXP));
        ptmp = REAL(tmp);

        for(int j = 0; j < LENGTH(tmp); j++) pans[i] += ptmp[j];

        pans[i] /= LENGTH(tmp);

        UNPROTECT(1);
    }

    UNPROTECT(1);
    return(ans);
')

#a very simple `lapply(x, mean)`
C_and_R = inline::cfunction(sig = c(R_ls = "list"), body = '
    SEXP call, ans, ret;

    PROTECT(call = allocList(2));
    SET_TYPEOF(call, LANGSXP);
    SETCAR(call, install("mean"));

    PROTECT(ans = allocVector(VECSXP, LENGTH(R_ls)));
    PROTECT(ret = allocVector(REALSXP, LENGTH(ans)));

    for(int i = 0; i < LENGTH(R_ls); i++) {
        SETCADR(call, VECTOR_ELT(R_ls, i));
        SET_VECTOR_ELT(ans, i, eval(call, R_GlobalEnv));
    }

    double *pret = REAL(ret);
    for(int i = 0; i < LENGTH(ans); i++) pret[i] = REAL(VECTOR_ELT(ans, i))[0];

    UNPROTECT(3);
    return(ret);
')                    

R_lapply = function(x) unlist(lapply(x, mean))                       

R_loop = function(x) 
{
    ans = numeric(length(x))
    for(i in seq_along(x)) ans[i] = mean(x[[i]])
    return(ans)
} 

R_loopcmp = compiler::cmpfun(R_loop)


set.seed(007); myls = replicate(1e4, runif(1e3), simplify = FALSE)
all.equal(all_C(myls), C_and_R(myls))
#[1] TRUE
all.equal(all_C(myls), R_lapply(myls))
#[1] TRUE
all.equal(all_C(myls), R_loop(myls))
#[1] TRUE
all.equal(all_C(myls), R_loopcmp(myls))
#[1] TRUE

microbenchmark::microbenchmark(all_C(myls), 
                               C_and_R(myls), 
                               R_lapply(myls), 
                               R_loop(myls), 
                               R_loopcmp(myls), 
                               times = 15)
#Unit: milliseconds
#            expr       min        lq    median        uq      max neval
#     all_C(myls)  37.29183  38.19107  38.69359  39.58083  41.3861    15
#   C_and_R(myls) 117.21457 123.22044 124.58148 130.85513 169.6822    15
#  R_lapply(myls)  98.48009 103.80717 106.55519 109.54890 116.3150    15
#    R_loop(myls) 122.40367 130.85061 132.61378 138.53664 178.5128    15
# R_loopcmp(myls) 105.63228 111.38340 112.16781 115.68909 128.1976    15
alexis_laz avatar Mar 11 '2015 12:03 alexis_laz

Para mí, la vectorización se trata principalmente de hacer que el código sea más fácil de escribir y de entender.

El objetivo de una función vectorizada es eliminar la contabilidad asociada con un bucle for. Por ejemplo, en lugar de:

means <- numeric(length(mtcars))
for (i in seq_along(mtcars)) {
  means[i] <- mean(mtcars[[i]])
}
sds <- numeric(length(mtcars))
for (i in seq_along(mtcars)) {
  sds[i] <- sd(mtcars[[i]])
}

Puedes escribir:

means <- vapply(mtcars, mean, numeric(1))
sds   <- vapply(mtcars, sd, numeric(1))

Eso hace que sea más fácil ver qué es igual (los datos de entrada) y qué es diferente (la función que estás aplicando).

Una ventaja secundaria de la vectorización es que el bucle for a menudo se escribe en C, en lugar de R. Esto tiene importantes beneficios de rendimiento, pero no creo que sea la propiedad clave de la vectorización. La vectorización se trata fundamentalmente de salvar tu cerebro, no de salvar el trabajo de la computadora.

hadley avatar Mar 11 '2015 12:03 hadley

Estoy de acuerdo con la opinión de Patrick Burns de que se trata más bien de ocultación de bucles y no de vectorización de código . Este es el por qué:

Considere este Cfragmento de código:

for (int i=0; i<n; i++)
  c[i] = a[i] + b[i]

Lo que nos gustaría hacer está bastante claro. Pero en realidad no lo es cómo se realiza la tarea o cómo se podría realizar. Un bucle for por defecto es una construcción en serie. No informa si se pueden hacer cosas en paralelo ni cómo.

La forma más obvia es que el código se ejecuta de forma secuencial . Cargue a[i]y b[i]continúe con los registros, agréguelos, almacene el resultado en c[i]y haga esto para cada uno i.

Sin embargo, los procesadores modernos tienen un conjunto de instrucciones vectoriales o SIMD que son capaces de operar en un vector de datos durante la misma instrucción cuando se realiza la misma operación (por ejemplo, sumar dos vectores como se muestra arriba). Dependiendo del procesador/arquitectura, podría ser posible sumar, digamos, cuatro números desde ay bbajo la misma instrucción, en lugar de uno a la vez.

Nos gustaría explotar los datos múltiples de instrucción única y realizar paralelismo a nivel de datos , es decir, cargar 4 cosas a la vez, agregar 4 cosas a la vez, almacenar 4 cosas a la vez, por ejemplo. Y esta es la vectorización de código .

Tenga en cuenta que esto es diferente de la paralelización de código, donde se realizan múltiples cálculos simultáneamente.

Sería fantástico si el compilador identificara dichos bloques de código y los vectorizara automáticamente , lo cual es una tarea difícil. La vectorización automática de códigos es un tema de investigación desafiante en Ciencias de la Computación. Pero con el tiempo, los compiladores han mejorado. Puede consultar las capacidades de vectorización automática GNU-gcc aquí . Lo mismo ocurre LLVM-clang aquí . Y también puede encontrar algunos puntos de referencia en el último enlace comparados con gccy ICC(compilador Intel C++).

gcc(Estoy en v4.9), por ejemplo, no vectoriza el código automáticamente en el -O2nivel de optimización. Entonces, si tuviéramos que ejecutar el código que se muestra arriba, se ejecutaría secuencialmente. Este es el momento para sumar dos vectores enteros de longitud 500 millones.

Necesitamos agregar la bandera -ftree-vectorizeo cambiar la optimización a nivel -O3. (Tenga en cuenta que también -O3realiza otras optimizaciones adicionales ). La bandera -fopt-info-veces útil ya que informa cuando un bucle se vectorizó con éxito).

# compiling with -O2, -ftree-vectorize and  -fopt-info-vec
# test.c:32:5: note: loop vectorized
# test.c:32:5: note: loop versioned for vectorization because of possible aliasing
# test.c:32:5: note: loop peeled for vectorization to enhance alignment    

Esto nos dice que la función está vectorizada. Estos son los tiempos que comparan las versiones vectorizadas y no vectorizadas en vectores enteros de longitud 500 millones:

x = sample(100L, 500e6L, TRUE)
y = sample(100L, 500e6L, TRUE)
z = vector("integer", 500e6L) # result vector

# non-vectorised, -O2
system.time(.Call("Csum", x, y, z))
#    user  system elapsed 
#   1.830   0.009   1.852

# vectorised using flags shown above at -O2
system.time(.Call("Csum", x, y, z))
#    user  system elapsed 
#   0.361   0.001   0.362

# both results are checked for identicalness, returns TRUE

Esta parte se puede omitir de forma segura sin perder continuidad.

Los compiladores no siempre tendrán suficiente información para vectorizar. Podríamos usar la especificación OpenMP para programación paralela , que también proporciona una directiva de compilador simd para indicar a los compiladores que vectoricen el código. Es esencial asegurarse de que no haya superposiciones de memoria, condiciones de carrera, etc. al vectorizar el código manualmente; de ​​lo contrario, se obtendrán resultados incorrectos.

#pragma omp simd
for (i=0; i<n; i++) 
  c[i] = a[i] + b[i]

Al hacer esto, le pedimos específicamente al compilador que lo vectorice pase lo que pase. Necesitaremos activar las extensiones OpenMP usando el indicador de tiempo de compilación -fopenmp. Haciendo eso:

# timing with -O2 + OpenMP with simd
x = sample(100L, 500e6L, TRUE)
y = sample(100L, 500e6L, TRUE)
z = vector("integer", 500e6L) # result vector
system.time(.Call("Cvecsum", x, y, z))
#    user  system elapsed 
#   0.360   0.001   0.360

which is great! This was tested with gcc v6.2.0 and llvm clang v3.9.0 (both installed via homebrew, MacOS 10.12.3), both of which support OpenMP 4.0.


In this sense, even though Wikipedia page on Array Programming mentions that languages that operate on entire arrays usually call that as vectorised operations, it really is loop hiding IMO (unless it is actually vectorised).

In case of R, even rowSums() or colSums() code in C don't exploit code vectorisation IIUC; it is just a loop in C. Same goes for lapply(). In case of apply(), it's in R. All of these are therefore loop hiding.

In short, wrapping an R function by:

just writing a for-loop in C != vectorising your code.
just writing a for-loop in R != vectorising your code.

Intel Math Kernel Library (MKL) for example implements vectorised forms of functions.

HTH


References:

  1. Talk by James Reinders, Intel (this answer is mostly an attempt to summarise this excellent talk)
Arun avatar Mar 13 '2015 00:03 Arun