Pourquoi Scala et des frameworks comme Spark et Scalding ont-ils à la fois reduce
et foldLeft
? Alors, quelle est la différence entre reduce
et fold
?
Pourquoi Scala et des frameworks comme Spark et Scalding ont-ils à la fois reduce
et foldLeft
? Alors, quelle est la différence entre reduce
et fold
?
Réponses:
Une grande différence, qui n'est mentionnée dans aucune autre réponse de stackoverflow relative à ce sujet clairement, est qu'il reduce
faut donner un monoïde commutatif , c'est-à-dire une opération à la fois commutative et associative. Cela signifie que l'opération peut être parallélisée.
Cette distinction est très importante pour le Big Data / MPP / l'informatique distribuée, et toute la raison pour laquelle reduce
elle existe même. La collection peut être découpée et le reduce
peut fonctionner sur chaque morceau, puis le reduce
peut fonctionner sur les résultats de chaque morceau - en fait, le niveau de segmentation n'a pas besoin de s'arrêter d'un niveau de profondeur. Nous pourrions aussi couper chaque morceau. C'est pourquoi la somme des entiers dans une liste est O (log N) si on lui donne un nombre infini de processeurs.
Si vous regardez simplement les signatures, il n'y a aucune raison reduce
d'exister car vous pouvez réaliser tout ce que vous pouvez reduce
avec un foldLeft
. La fonctionnalité de foldLeft
est supérieure à la fonctionnalité de reduce
.
Mais vous ne pouvez pas paralléliser a foldLeft
, donc son runtime est toujours O (N) (même si vous alimentez un monoïde commutatif). En effet, on suppose que l'opération n'est pas un monoïde commutatif et que la valeur cumulée sera donc calculée par une série d'agrégations séquentielles.
foldLeft
ne suppose ni commutativité ni associativité. C'est l'associativité qui donne la possibilité de découper la collection, et c'est la commutativité qui facilite le cumul car l'ordre n'est pas important (donc peu importe l'ordre d'agréger chacun des résultats de chacun des blocs). La commutativité à proprement parler n'est pas nécessaire pour la parallélisation, par exemple les algorithmes de tri distribué, elle facilite simplement la logique car vous n'avez pas besoin de donner un ordre à vos morceaux.
Si vous jetez un œil à la documentation Spark, reduce
elle dit spécifiquement "... opérateur binaire commutatif et associatif"
http://spark.apache.org/docs/1.0.0/api/scala/index.html#org.apache.spark.rdd.RDD
Voici la preuve qui reduce
n'est PAS juste un cas particulier defoldLeft
scala> val intParList: ParSeq[Int] = (1 to 100000).map(_ => scala.util.Random.nextInt()).par
scala> timeMany(1000, intParList.reduce(_ + _))
Took 462.395867 milli seconds
scala> timeMany(1000, intParList.foldLeft(0)(_ + _))
Took 2589.363031 milli seconds
Maintenant, c'est là que cela se rapproche un peu plus des racines FP / mathématiques, et un peu plus difficile à expliquer. Réduire est défini formellement dans le cadre du paradigme MapReduce, qui traite des collections sans ordre (multisets), Fold est formellement défini en termes de récursivité (voir catamorphisme) et suppose donc une structure / séquence pour les collections.
Il n'y a pas de fold
méthode dans Scalding car sous le modèle de programmation (strict) Map Reduce, nous ne pouvons pas définir fold
parce que les morceaux n'ont pas d'ordre et fold
ne nécessitent que l'associativité, pas la commutativité.
En termes simples, reduce
fonctionne sans ordre de cumul, fold
nécessite un ordre de cumul et c'est cet ordre de cumul qui nécessite une valeur zéro PAS l'existence de la valeur zéro qui les distingue. Strictement parlant, reduce
devrait fonctionner sur une collection vide, car sa valeur zéro peut être déduite en prenant une valeur arbitraire x
puis en la résolvant x op y = x
, mais cela ne fonctionne pas avec une opération non commutative car il peut exister une valeur zéro gauche et droite distincte (ie x op y != y op x
). Bien sûr, Scala ne prend pas la peine de déterminer quelle est cette valeur zéro car cela nécessiterait de faire des mathématiques (qui sont probablement non calculables), alors lève simplement une exception.
Il semble (comme c'est souvent le cas en étymologie) que cette signification mathématique originelle a été perdue, puisque la seule différence évidente dans la programmation est la signature. Le résultat est que c'est reduce
devenu un synonyme de fold
, plutôt que de préserver sa signification originale de MapReduce. Maintenant, ces termes sont souvent utilisés de manière interchangeable et se comportent de la même manière dans la plupart des implémentations (ignorant les collections vides). La bizarrerie est exacerbée par des particularités, comme dans Spark, que nous allons maintenant aborder.
Spark a donc un fold
, mais l'ordre dans lequel les sous-résultats (un pour chaque partition) sont combinés (au moment de l'écriture) est le même ordre dans lequel les tâches sont terminées - et donc non déterministe. Merci à @CafeFeed d'avoir souligné cette fold
utilisation runJob
, qui après avoir lu le code, j'ai réalisé que ce n'était pas déterministe. Une confusion supplémentaire est créée par Spark ayant un treeReduce
mais non treeFold
.
Il y a une différence entre reduce
etfold
même lorsqu'il est appliqué à des séquences non vides. Le premier est défini comme faisant partie du paradigme de programmation MapReduce sur les collections avec un ordre arbitraire ( http://theory.stanford.edu/~sergei/papers/soda10-mrc.pdf ) et on devrait supposer que les opérateurs sont commutatifs en plus d'être associatif pour donner des résultats déterministes. Ce dernier est défini en termes de catomorphismes et nécessite que les collections aient une notion de séquence (ou soient définies de manière récursive, comme les listes chaînées), donc ne nécessitent pas d'opérateurs commutatifs.
En pratique, en raison de la nature non mathématique de la programmation, reduce
et fold
ont tendance à se comporter de la même manière, soit correctement (comme dans Scala), soit incorrectement (comme dans Spark).
Mon opinion est que la confusion serait évitée si l'utilisation du terme fold
était complètement abandonnée dans Spark. Au moins, Spark a une note dans sa documentation:
Cela se comporte quelque peu différemment des opérations de repli implémentées pour les collections non distribuées dans des langages fonctionnels comme Scala.
foldLeft
contient le Left
dans son nom et pourquoi il existe également une méthode appelée fold
.
.par
, (List(1000000.0) ::: List.tabulate(100)(_ + 0.001)).par.reduce(_ / _)
j'obtiens donc des résultats différents à chaque fois.
reallyFold
proxénète, comme :, rdd.mapPartitions(it => Iterator(it.fold(zero)(f)))).collect().fold(zero)(f)
cela n'aurait pas besoin de f pour faire la navette.
Si je ne me trompe pas, même si l'API Spark ne l'exige pas, fold exige également que le f soit commutatif. Parce que l'ordre dans lequel les partitions seront agrégées n'est pas garanti. Par exemple, dans le code suivant, seule la première impression est triée:
import org.apache.spark.{SparkConf, SparkContext}
object FoldExample extends App{
val conf = new SparkConf()
.setMaster("local[*]")
.setAppName("Simple Application")
implicit val sc = new SparkContext(conf)
val range = ('a' to 'z').map(_.toString)
val rdd = sc.parallelize(range)
println(range.reduce(_ + _))
println(rdd.reduce(_ + _))
println(rdd.fold("")(_ + _))
}
Imprimer:
abcdefghijklmnopqrstuvwxyz
abcghituvjklmwxyzqrsdefnop
defghinopjklmqrstuvabcwxyz
sc.makeRDD(0 to 9, 2).mapPartitions(it => { java.lang.Thread.sleep(new java.util.Random().nextInt(1000)); it } ).map(_.toString).fold("")(_ + _)
plusieurs fois avec plus de 2 cœurs, je pense que vous verrez que cela produit un ordre aléatoire (par partition). J'ai mis à jour ma réponse en conséquence.
fold
dans Apache Spark n'est pas la même chose que fold
sur les collections non distribuées. En fait, il faut une fonction commutative pour produire des résultats déterministes:
Cela se comporte quelque peu différemment des opérations de repli implémentées pour les collections non distribuées dans des langages fonctionnels comme Scala. Cette opération de pliage peut être appliquée aux partitions individuellement, puis plier ces résultats dans le résultat final, plutôt que d'appliquer le pli à chaque élément séquentiellement dans un certain ordre défini. Pour les fonctions non commutatives, le résultat peut différer de celui d'un repli appliqué à une collection non distribuée.
Cela a été démontré par Mishael Rosenthal et suggéré par Make42 dans son commentaire .
Il a été suggéré que le comportement observé est lié au HashPartitioner
moment où, en fait, parallelize
il ne mélange pas et n'utilise pas HashPartitioner
.
import org.apache.spark.sql.SparkSession
/* Note: standalone (non-local) mode */
val master = "spark://...:7077"
val spark = SparkSession.builder.master(master).getOrCreate()
/* Note: deterministic order */
val rdd = sc.parallelize(Seq("a", "b", "c", "d"), 4).sortBy(identity[String])
require(rdd.collect.sliding(2).forall { case Array(x, y) => x < y })
/* Note: all posible permutations */
require(Seq.fill(1000)(rdd.fold("")(_ + _)).toSet.size == 24)
Expliqué:
Structure de fold
pour RDD
def fold(zeroValue: T)(op: (T, T) => T): T = withScope {
var jobResult: T
val cleanOp: (T, T) => T
val foldPartition = Iterator[T] => T
val mergeResult: (Int, T) => Unit
sc.runJob(this, foldPartition, mergeResult)
jobResult
}
est le même que la structure dereduce
pour RDD:
def reduce(f: (T, T) => T): T = withScope {
val cleanF: (T, T) => T
val reducePartition: Iterator[T] => Option[T]
var jobResult: Option[T]
val mergeResult = (Int, Option[T]) => Unit
sc.runJob(this, reducePartition, mergeResult)
jobResult.getOrElse(throw new UnsupportedOperationException("empty collection"))
}
où runJob
est exécuté sans tenir compte de l'ordre de partition et entraîne le besoin d'une fonction commutative.
foldPartition
et reducePartition
sont équivalents en termes d'ordre de traitement et effectivement (par héritage et délégation) mis en œuvre par reduceLeft
etfoldLeft
sur TraversableOnce
.
Conclusion: fold
sur RDD ne peut pas dépendre de l'ordre des morceaux et nécessite commutativité et associativité .
fold
sur RDD
s est vraiment exactement la même chose que reduce
, mais cela ne respecte pas les différences mathématiques fondamentales (j'ai mis à jour ma réponse pour être encore plus claire). Bien que je ne sois pas d'accord sur le fait que nous ayons vraiment besoin de commutativité à condition que l'on soit sûr de tout ce que fait son partisan, cela préserve l'ordre.
runJob
code, je vois qu'en effet, il fait la combinaison en fonction du moment où une tâche est terminée, PAS de l'ordre des partitions. C'est ce détail clé qui fait que tout se met en place. J'ai de nouveau édité ma réponse et corrigé ainsi l'erreur que vous signalez. Pouvez-vous retirer votre prime puisque nous sommes maintenant d'accord?
Une autre différence pour Scalding est l'utilisation de combineurs dans Hadoop.
Imaginez que votre opération soit monoïde commutative, avec la réduction, elle sera également appliquée du côté de la carte au lieu de mélanger / trier toutes les données vers les réducteurs. Ce n'est pas le cas avec foldLeft .
pipe.groupBy('product) {
_.reduce('price -> 'total){ (sum: Double, price: Double) => sum + price }
// reduce is .mapReduceMap in disguise
}
pipe.groupBy('product) {
_.foldLeft('price -> 'total)(0.0){ (sum: Double, price: Double) => sum + price }
}
Il est toujours bon de définir vos opérations comme monoïdes dans Scalding.