web-dev-qa-db-fra.com

La famille "* appliquer" n'est-elle pas vraiment vectorisée?

Nous avons donc l'habitude de dire à chaque nouvel utilisateur de R que "apply n'est pas vectorisé, consultez Patrick Burns R Inferno Circle 4" qui dit (je cite):

Un réflexe courant consiste à utiliser une fonction dans la famille d'application. Ce n'est pas vectorisation, il cache des boucles . La fonction apply a une boucle for dans sa définition. La fonction lapply enterre la boucle, mais les temps d'exécution ont tendance à être à peu près égaux à une boucle for explicite.

En effet, un rapide coup d'œil sur le code source apply révèle la boucle:

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

Ok jusqu'à présent, mais un coup d'oeil à lapply ou vapply révèle en fait une image complètement différente:

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>

Donc apparemment il n'y a pas de boucle R for qui s'y cache, ils appellent plutôt une fonction écrite C interne.

Un coup d'œil rapide dans le lapintro révèle à peu près la même image

De plus, prenons la fonction colMeans par exemple, qui n'a jamais été accusée de ne pas être vectorisée

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>

Hein? Il appelle aussi simplement .Internal(colMeans(... que l'on retrouve également dans le lapin . Alors, comment est-ce différent de .Internal(lapply(..?

En fait, un benchmark rapide révèle que sapply ne fonctionne pas moins bien que colMeans et bien mieux qu'une boucle for pour un grand ensemble de données

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 d'autres termes, est-il correct de dire que lapply et vapply sont en fait vectorisés (par rapport à apply qui est une boucle for qui appelle également lapply) et que voulait vraiment dire Patrick Burns?

131
David Arenburg

Tout d'abord, dans votre exemple, vous faites des tests sur un "data.frame" qui n'est pas juste pour colMeans, apply et "[.data.frame" Car ils ont une surcharge:

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

Sur une matrice, l'image est un peu différente:

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

En recadrant la partie principale de la question, la principale différence entre lapply/mapply/etc et les boucles R simples est l'endroit où le bouclage est effectué. Comme le note Roland, les boucles C et R doivent évaluer une fonction R à chaque itération qui est la plus coûteuse. Les fonctions C vraiment rapides sont celles qui font tout en C, donc je suppose que c'est ça le "vectorisé"?

Un exemple où nous trouvons la moyenne dans chacun des éléments d'une "liste":

(EDIT 11 mai 16: Je crois que l'exemple avec la recherche de la "moyenne" n'est pas une bonne configuration pour les différences entre l'évaluation itérative d'une fonction R et le code compilé , (1) en raison de la particularité de l'algorithme moyen de R sur les "numériques" sur une simple sum(x) / length(x) et (2) il devrait être plus logique de tester sur les "listes" avec length(x) >> lengths(x). Ainsi, l'exemple "moyenne" est déplacé à la fin et remplacé par un autre.)

Comme exemple simple, nous pourrions considérer la recherche de l'opposé de chaque élément length == 1 D'une "liste":

Dans un fichier tmp.c:

#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);
}

Et côté R:

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

avec des données:

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)
})

Analyse comparative:

#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

(Suit l'exemple original de découverte moyenne):

#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
72
alexis_laz

Pour moi, la vectorisation consiste principalement à rendre votre code plus facile à écrire et à comprendre.

Le but d'une fonction vectorisée est d'éliminer la comptabilité associée à une boucle for. Par exemple, au lieu 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]])
}

Tu peux écrire:

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

Cela permet de voir plus facilement ce qui est le même (les données d'entrée) et ce qui est différent (la fonction que vous appliquez).

Un avantage secondaire de la vectorisation est que la boucle for est souvent écrite en C plutôt qu'en R. Cela présente des avantages de performances substantiels, mais je ne pense pas que ce soit la propriété clé de la vectorisation. La vectorisation consiste fondamentalement à sauver votre cerveau, pas à sauvegarder le travail informatique.

63
hadley

Je suis d'accord avec l'avis de Patrick Burns selon lequel il s'agit plutôt de masquer les boucles et non de vectoriser le code . Voici pourquoi:

Considérez cet extrait de code C:

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

Ce que nous aimerions faire est assez clair. Mais comment la tâche est exécutée ou comment elle pourrait être effectuée ne l'est pas vraiment. Un for-loop par défaut est une construction série. Il n'informe pas si ou comment les choses peuvent être faites en parallèle.

Le moyen le plus évident est que le code est exécuté de manière séquentielle . Chargez a[i] Et b[i] Dans les registres, ajoutez-les, stockez le résultat dans c[i], Et faites-le pour chaque i.

Cependant, les processeurs modernes ont vecteur ou [~ # ~] simd [~ # ~] jeu d'instructions capable de fonctionner sur un vecteur de données pendant le même instruction lors de l'exécution de la même opération (par exemple, en ajoutant deux vecteurs comme indiqué ci-dessus). Selon le processeur/l'architecture, il peut être possible d'ajouter, disons, quatre nombres parmi a et b sous la même instruction, au lieu d'un à la fois.

Nous aimerions exploiter le données multiples à instruction unique et effectuer le parallélisme au niveau des données , c'est-à-dire charger 4 choses à la fois, ajouter 4 choses à la fois, stocker 4 choses à la fois, par exemple. Et ceci est vectorisation de code .

Notez que ceci est différent de la parallélisation de code - où plusieurs calculs sont effectués simultanément.

Ce serait formidable si le compilateur identifie de tels blocs de code et automatiquement les vectorise, ce qui est une tâche difficile. Vectorisation automatique de code est un sujet de recherche difficile en informatique. Mais au fil du temps, les compilateurs se sont améliorés. Vous pouvez vérifier les capacités de vectorisation automatique de GNU-gccici . De même pour LLVM-clangici . Et vous pouvez également trouver des repères dans le dernier lien par rapport à gcc et ICC (compilateur Intel C++).

gcc (je suis sur v4.9) par exemple ne vectorise pas automatiquement le code à l'optimisation de niveau -O2. Donc, si nous devions exécuter le code ci-dessus, il serait exécuté de manière séquentielle. Voici le moment pour ajouter deux vecteurs entiers de longueur 500 millions.

Nous devons soit ajouter le drapeau -ftree-vectorize Soit changer l'optimisation au niveau -O3. (Notez que -O3 Effectue également autres optimisations supplémentaires ). Le drapeau -fopt-info-vec est utile car il informe quand une boucle a été vectorisée avec succès).

# 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    

Cela nous indique que la fonction est vectorisée. Voici les timings comparant les versions non vectorisées et vectorisées sur des vecteurs entiers de longueur 500 millions:

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

Cette partie peut être sautée en toute sécurité sans perte de continuité.

Les compilateurs ne disposeront pas toujours d'informations suffisantes pour vectoriser. Nous pourrions utiliser spécification OpenMP pour la programmation parallèle , qui fournit également une directive de compilation simd pour demander aux compilateurs de vectoriser le code. Il est essentiel de s'assurer qu'il n'y a pas de chevauchement de mémoire, de conditions de concurrence, etc. lors de la vectorisation manuelle du code, sinon cela entraînera des résultats incorrects.

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

En faisant cela, nous demandons spécifiquement au compilateur de le vectoriser quoi qu'il arrive. Nous devrons activer les extensions OpenMP en utilisant l'indicateur de temps de compilation -fopenmp . En faisant cela:

# 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

qui est genial! Cela a été testé avec gcc v6.2.0 et llvm clang v3.9.0 (tous deux installés via homebrew, MacOS 10.12.3), tous deux prenant en charge OpenMP 4.0.


En ce sens, même si page Wikipedia sur la programmation de tableaux mentionne que les langages qui fonctionnent sur des tableaux entiers appellent généralement cela comme opérations vectorisées , c'est vraiment cacher la boucle IMO (à moins qu'il ne soit réellement vectorisé).

Dans le cas de R, même le code rowSums() ou colSums() en C n'exploite pas la vectorisation du code IIUC; c'est juste une boucle en C. Il en va de même pour lapply(). Dans le cas de apply(), c'est dans R. Tous ces éléments sont donc masquage de boucle .

En bref, encapsuler une fonction R en:

il suffit d'écrire un for-loop dans C! = vectoriser votre code.
il suffit d'écrire un for-loop dans R! = vectoriser votre code.

Intel Math Kernel Library (MKL) implémente par exemple des formes vectorisées de fonctions.

HTH


Les références:

  1. Discours de James Reinders, Intel (cette réponse est principalement une tentative de résumer cet excellent discours)
46
Arun

Donc, pour résumer les bonnes réponses/commentaires en une réponse générale et fournir un contexte: R a 4 types de boucles ( de l'ordre non vectorisé à l'ordre vectorisé )

  1. Boucle R for qui appelle à plusieurs reprises les fonctions R dans chaque itération ( Non vectorisée )
  2. Boucle C qui appelle à plusieurs reprises les fonctions R dans chaque itération ( Non vectorisé )
  3. Boucle C qui n'appelle la fonction R qu'une seule fois ( Assez vectorisé )
  4. Une boucle C simple qui n'appelle pas du tout la fonction n'importe laquelle R et utilise ses propres fonctions compilées ( Vectorisée )

La famille *apply Est donc le deuxième type. Sauf apply qui est plutôt du premier type

Vous pouvez comprendre cela à partir du commentaire dans son code source

/ * .Internal (lapply (X, FUN)) * /

/ * Ceci est un .Internal spécial, donc a des arguments non évalués. C'est
appelé depuis une enveloppe de fermeture, donc X et FUN sont des promesses. FUN ne doit pas être évalué pour être utilisé par ex. bquote. * /

Cela signifie que le code C lapplys accepte une fonction non évaluée de R et l'évalue plus tard dans le code C lui-même. C'est fondamentalement la différence entre lapplys .Internal Appel

.Internal(lapply(X, FUN))

Qui a un argument FUN qui contient une fonction R

Et l'appel colMeans.Internal Qui n'en a pas a un argument FUN

.Internal(colMeans(Re(x), n, prod(dn), na.rm))

colMeans, contrairement à lapply sait exactement quelle fonction il doit utiliser, donc il calcule la moyenne en interne dans le code C.

Vous pouvez voir clairement le processus d'évaluation de la fonction R dans chaque itération dans lapply code C

 for(R_xlen_t i = 0; i < n; i++) {
      if (realIndx) REAL(ind)[0] = (double)(i + 1);
      else INTEGER(ind)[0] = (int)(i + 1);
      tmp = eval(R_fcall, rho);   // <----------------------------- here it is
      if (MAYBE_REFERENCED(tmp)) tmp = lazy_duplicate(tmp);
      SET_VECTOR_ELT(ans, i, tmp);
   }

Pour résumer, lapply n'est pas vectorisé , bien qu'il présente deux avantages possibles par rapport à la boucle R for simple

  1. L'accès et l'affectation dans une boucle semble être plus rapide en C (c'est-à-dire dans lapplying une fonction) Bien que la différence semble grande, nous restons toujours au niveau microseconde et la chose coûteuse est la valorisation d'une fonction R à chaque itération. Un exemple simple:

    ffR = function(x)  {
        ans = vector("list", length(x))
        for(i in seq_along(x)) ans[[i]] = x[[i]]
        ans 
    }
    
    ffC = inline::cfunction(sig = c(R_x = "data.frame"), body = '
        SEXP ans;
        PROTECT(ans = allocVector(VECSXP, LENGTH(R_x)));
        for(int i = 0; i < LENGTH(R_x); i++) 
               SET_VECTOR_ELT(ans, i, VECTOR_ELT(R_x, i));
        UNPROTECT(1);
        return(ans); 
    ')
    
    set.seed(007) 
    myls = replicate(1e3, runif(1e3), simplify = FALSE)     
    mydf = as.data.frame(myls)
    
    all.equal(ffR(myls), ffC(myls))
    #[1] TRUE 
    all.equal(ffR(mydf), ffC(mydf))
    #[1] TRUE
    
    microbenchmark::microbenchmark(ffR(myls), ffC(myls), 
                                   ffR(mydf), ffC(mydf),
                                   times = 30)
    #Unit: microseconds
    #      expr       min        lq    median        uq       max neval
    # ffR(myls)  3933.764  3975.076  4073.540  5121.045 32956.580    30
    # ffC(myls)    12.553    12.934    16.695    18.210    19.481    30
    # ffR(mydf) 14799.340 15095.677 15661.889 16129.689 18439.908    30
    # ffC(mydf)    12.599    13.068    15.835    18.402    20.509    30
    
  2. Comme mentionné par @Roland, il exécute une boucle C compilée plutôt qu'une boucle R interprétée


Cependant, lors de la vectorisation de votre code, vous devez prendre en compte certaines choses.

  1. Si votre ensemble de données (appelons-le df) est de classe data.frame, Certaines fonctions vectorisées (telles que colMeans, colSums, rowSums, etc.) devra d'abord le convertir en matrice, simplement parce que c'est ainsi qu'ils ont été conçus. Cela signifie que pour un gros df cela peut créer une énorme surcharge. Alors que lapply n'aura pas à le faire car il extrait les vecteurs réels de df (car data.frame N'est qu'une liste de vecteurs) et donc, si vous ne l'avez pas autant de colonnes mais de lignes, lapply(df, mean) peut parfois être une meilleure option que colMeans(df).
  2. Une autre chose à retenir est que R a une grande variété de types de fonctions différents, tels que .Primitive, Et génériques (S3, S4) Voir ici = pour quelques informations supplémentaires. La fonction générique doit faire une répartition de méthode qui parfois une opération coûteuse. Par exemple, mean est la fonction générique S3 Tandis que sum est Primitive. Ainsi, parfois lapply(df, sum) pourrait être très efficace par rapport à colSums pour les raisons énumérées ci-dessus
35
David Arenburg