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?
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
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.
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-gcc
ici . De même pour LLVM-clang
ici . 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 dansR
! = vectoriser votre code.Intel Math Kernel Library (MKL) implémente par exemple des formes vectorisées de fonctions.
HTH
Les références:
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é )
for
qui appelle à plusieurs reprises les fonctions R dans chaque itération ( Non 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 lapply
s 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 lapply
s .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
L'accès et l'affectation dans une boucle semble être plus rapide en C (c'est-à-dire dans lapply
ing 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
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.
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)
..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