Différence entre réduire et foldLeft / fold dans la programmation fonctionnelle (en particulier les API Scala et Scala)?


Réponses:


260

réduire vs replier

Une grande différence, qui n'est mentionnée dans aucune autre réponse de stackoverflow relative à ce sujet clairement, est qu'il reducefaut 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 reduceelle existe même. La collection peut être découpée et le reducepeut fonctionner sur chaque morceau, puis le reducepeut 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 reduced'exister car vous pouvez réaliser tout ce que vous pouvez reduceavec un foldLeft. La fonctionnalité de foldLeftest 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.

foldLeftne 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, reduceelle 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 reducen'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

réduire vs plier

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 foldméthode dans Scalding car sous le modèle de programmation (strict) Map Reduce, nous ne pouvons pas définir foldparce que les morceaux n'ont pas d'ordre et foldne nécessitent que l'associativité, pas la commutativité.

En termes simples, reducefonctionne sans ordre de cumul, foldné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 xpuis 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 reducedevenu 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 foldutilisation 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 treeReducemais non treeFold.

Conclusion

Il y a une différence entre reduceetfold 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 foldont tendance à se comporter de la même manière, soit correctement (comme dans Scala), soit incorrectement (comme dans Spark).

Extra: Mon avis sur l'API 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.


2
C'est pourquoi foldLeftcontient le Leftdans son nom et pourquoi il existe également une méthode appelée fold.
kiritsuku

1
@Cloudtech C'est une coïncidence de son implémentation à thread unique, pas dans ses spécifications. Sur ma machine 4 cœurs, si j'essaie d'ajouter .par, (List(1000000.0) ::: List.tabulate(100)(_ + 0.001)).par.reduce(_ / _)j'obtiens donc des résultats différents à chaque fois.
samthebest

2
@AlexDean dans le contexte de l'informatique, non, il n'a pas vraiment besoin d'une identité car les collections vides ont tendance à simplement lancer des exceptions. Mais c'est mathématiquement plus élégant (et ce serait plus élégant si les collections faisaient cela) si l'élément d'identité est renvoyé lorsque la collection est vide. En mathématiques, «lancer une exception» n'existe pas.
samthebest

3
@samthebest: Êtes-vous sûr de la commutativité? github.com/apache/spark/blob/… dit "Pour les fonctions qui ne sont pas commutatives, le résultat peut différer de celui d'un repli appliqué à une collection non distribuée."
Make42

1
@ Make42 C'est vrai, on pourrait écrire son propre reallyFoldproxé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.
samthebest

10

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


Après quelques allers-retours, nous pensons que vous avez raison. L'ordre de combinaison est le premier arrivé, premier servi. Si vous exécutez 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.
samthebest

3

folddans Apache Spark n'est pas la même chose que foldsur 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 HashPartitionermoment où, en fait, parallelizeil 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"))
}

runJobest exécuté sans tenir compte de l'ordre de partition et entraîne le besoin d'une fonction commutative.

foldPartitionet reducePartitionsont équivalents en termes d'ordre de traitement et effectivement (par héritage et délégation) mis en œuvre par reduceLeftetfoldLeft sur TraversableOnce.

Conclusion: foldsur RDD ne peut pas dépendre de l'ordre des morceaux et nécessite commutativité et associativité .


Je dois admettre que l'étymologie est confuse et que la littérature sur la programmation manque de définitions formelles. Je pense qu'il est prudent de dire que foldsur RDDs 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.
samthebest

L'ordre de repli indéfini n'est pas lié au partitionnement. C'est une conséquence directe d'une implémentation runJob.

Ah! Désolé, je n'ai pas pu comprendre votre point de vue, mais après avoir lu le runJobcode, 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?
samthebest

Je ne peux pas modifier ou supprimer - il n'y a pas une telle option. Je peux attribuer un prix mais je pense que vous obtenez pas mal de points d'une seule attention, est-ce que je me trompe? Si vous confirmez que vous voulez que je récompense, je le fais dans les prochaines 24 heures. Merci pour les corrections et désolé pour une méthode, mais il semblait que vous ignoriez tous les avertissements, c'est une grande chose, et la réponse a été citée partout.

1
Que diriez-vous de l'attribuer à @Mishael Rosenthal puisqu'il a été le premier à exprimer clairement sa préoccupation. Je n'ai aucun intérêt pour les points, j'aime juste utiliser SO pour le référencement et l'organisation.
samthebest

2

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.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.