Differenza tra reduce e foldLeft / fold nella programmazione funzionale (in particolare Scala e API Scala)?


Risposte:


260

reduce vs foldLeft

Una grande grande differenza, non menzionata in qualsiasi altra risposta di stackoverflow relativa a questo argomento chiaramente, è che reducedovrebbe essere assegnato un monoide commutativo , cioè un'operazione che è sia commutativa che associativa. Ciò significa che l'operazione può essere parallelizzata.

Questa distinzione è molto importante per Big Data / MPP / calcolo distribuito e l'intera ragione per cui reduceesiste. La raccolta può essere sminuzzata e reducepuò operare su ogni blocco, quindi reducepuò operare sui risultati di ogni blocco - infatti il ​​livello di suddivisione non deve fermarsi a un livello profondo. Potremmo anche sminuzzare ogni pezzo. Questo è il motivo per cui la somma degli interi in una lista è O (log N) se viene fornito un numero infinito di CPU.

Se guardi solo le firme non c'è motivo reducedi esistere perché puoi ottenere tutto ciò che puoi reducecon un file foldLeft. La funzionalità di foldLeftè maggiore della funzionalità di reduce.

Ma non puoi parallelizzare a foldLeft, quindi il suo tempo di esecuzione è sempre O (N) (anche se inserisci un monoide commutativo). Questo perché si presume che l'operazione non sia un monoide commutativo e quindi il valore cumulato verrà calcolato da una serie di aggregazioni sequenziali.

foldLeftnon assume commutatività né associatività. È l'associatività che dà la possibilità di dividere la raccolta, ed è la commutatività che rende facile il cumulo perché l'ordine non è importante (quindi non importa quale ordine aggregare ciascuno dei risultati da ciascuno dei blocchi). A rigor di termini, la commutatività non è necessaria per la parallelizzazione, ad esempio gli algoritmi di ordinamento distribuito, ma semplifica semplicemente la logica perché non è necessario dare un ordine ai blocchi.

Se dai un'occhiata alla documentazione di Spark reduce, dice specificamente "... operatore binario commutativo e associativo"

http://spark.apache.org/docs/1.0.0/api/scala/index.html#org.apache.spark.rdd.RDD

Ecco la prova che reduceNON è solo un caso speciale difoldLeft

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

ridurre vs piegare

Ora è qui che ci si avvicina un po 'alle radici matematiche / FP e un po' più complicato da spiegare. Reduce è definito formalmente come parte del paradigma MapReduce, che si occupa di raccolte senza ordine (multiset), Fold è formalmente definito in termini di ricorsione (vedi catamorfismo) e quindi assume una struttura / sequenza per le raccolte.

Non esiste un foldmetodo in Scalding perché sotto il modello di programmazione Map Reduce (rigoroso) non possiamo definirlo foldperché i blocchi non hanno un ordine e foldrichiedono solo l'associatività, non la commutatività.

In parole povere, reducefunziona senza un ordine di cumulo, foldrichiede un ordine di cumulo ed è quell'ordine di cumulo che richiede un valore zero NON l'esistenza del valore zero che li distingue. A rigor di termini reduce dovrebbe funzionare su una raccolta vuota, perché il suo valore zero può essere dedotto prendendo un valore arbitrario xe poi risolvendo x op y = x, ma ciò non funziona con un'operazione non commutativa in quanto può esistere un valore zero sinistro e destro distinti (cioè x op y != y op x). Ovviamente Scala non si preoccupa di capire quale sia questo valore zero in quanto ciò richiederebbe di fare un po 'di matematica (che probabilmente non è computabile), quindi lancia un'eccezione.

Sembra (come spesso accade in etimologia) che questo significato matematico originale sia andato perduto, poiché l'unica differenza evidente nella programmazione è la firma. Il risultato è che reduceè diventato un sinonimo di foldMapReduce, anziché preservare il suo significato originale. Ora questi termini sono spesso usati in modo intercambiabile e si comportano allo stesso modo nella maggior parte delle implementazioni (ignorando le raccolte vuote). La stranezza è esacerbata da peculiarità, come in Spark, che ora affronteremo.

Così Spark non hanno una fold, ma l'ordine in cui vengono combinati sub risultati (uno per ogni partizione) (al momento della scrittura) è lo stesso ordine in cui sono espletate le attività - e quindi non deterministica. Grazie a @CafeFeed per aver sottolineato che foldutilizza runJob, che dopo aver letto il codice mi sono reso conto che non è deterministico. Ulteriore confusione è creata da Spark che ha un treeReducema no treeFold.

Conclusione

C'è una differenza tra reducee foldanche quando applicato a sequenze non vuote. Il primo è definito come parte del paradigma di programmazione MapReduce su raccolte con ordine arbitrario ( http://theory.stanford.edu/~sergei/papers/soda10-mrc.pdf ) e si dovrebbe presumere che gli operatori siano commutativi oltre ad essere associativo per dare risultati deterministici. Quest'ultimo è definito in termini di catomorfismi e richiede che le collezioni abbiano una nozione di sequenza (o siano definite ricorsivamente, come liste concatenate), quindi non necessitano di operatori commutativi.

In pratica a causa della natura non matematica della programmazione, reducee foldtendono a comportarsi allo stesso modo, correttamente (come in Scala) o non correttamente (come in Spark).

Extra: la mia opinione sull'API Spark

La mia opinione è che la confusione sarebbe evitata se l'uso del termine foldfosse completamente abbandonato in Spark. Almeno Spark ha una nota nella loro documentazione:

Questo si comporta in modo un po 'diverso dalle operazioni di piegatura implementate per raccolte non distribuite in linguaggi funzionali come Scala.


2
Ecco perché foldLeftcontiene il Leftnel suo nome e perché c'è anche un metodo chiamato fold.
kiritsuku

1
@Cloudtech Questa è una coincidenza della sua implementazione a thread singolo, non all'interno delle sue specifiche. Sulla mia macchina a 4 core, se provo ad aggiungere .par, (List(1000000.0) ::: List.tabulate(100)(_ + 0.001)).par.reduce(_ / _)ottengo risultati diversi ogni volta.
samthebest

2
@AlexDean nel contesto dell'informatica, no, non ha davvero bisogno di un'identità poiché le raccolte vuote tendono a generare solo eccezioni. Ma è matematicamente più elegante (e sarebbe più elegante se le raccolte lo facessero) se l'elemento identità fosse restituito quando la raccolta è vuota. In matematica "lancia un'eccezione" non esiste.
samthebest

3
@samthebest: sei sicuro della commutatività? github.com/apache/spark/blob/… dice "Per le funzioni che non sono commutative, il risultato potrebbe differire da quello di una piega applicata a una raccolta non distribuita."
Make42

1
@ Make42 È corretto, si potrebbe scrivere il proprio reallyFoldmagnaccia, come :, rdd.mapPartitions(it => Iterator(it.fold(zero)(f)))).collect().fold(zero)(f)questo non avrebbe bisogno di f per andare al lavoro.
samthebest

10

Se non sbaglio, anche se l'API Spark non lo richiede, fold richiede anche che la f sia commutativa. Perché l'ordine in cui le partizioni verranno aggregate non è garantito. Ad esempio nel codice seguente viene ordinata solo la prima stampa:

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("")(_ + _))
}  

Stampare:

abcdefghijklmnopqrstuvwxyz

abcghituvjklmwxyzqrsdefnop

defghinopjklmqrstuvabcwxyz


Dopo un po 'di avanti e indietro, crediamo che tu abbia ragione. L'ordine di combinazione è primo arrivato, primo servito. Se esegui sc.makeRDD(0 to 9, 2).mapPartitions(it => { java.lang.Thread.sleep(new java.util.Random().nextInt(1000)); it } ).map(_.toString).fold("")(_ + _)più volte con 2 o più core, penso che vedrai che produce un ordine casuale (in base alla partizione). Ho aggiornato di conseguenza la mia risposta.
samthebest

3

foldin Apache Spark non è lo stesso delle foldraccolte non distribuite. Infatti richiede una funzione commutativa per produrre risultati deterministici:

Questo si comporta in modo un po 'diverso dalle operazioni di piegatura implementate per raccolte non distribuite in linguaggi funzionali come Scala. Questa operazione di piegatura può essere applicata alle partizioni individualmente, quindi piega quei risultati nel risultato finale, piuttosto che applicare la piega a ciascun elemento in sequenza in un ordine definito. Per le funzioni che non sono commutative, il risultato può differire da quello di una piega applicata a una raccolta non distribuita.

Questo è stato dimostrato da Mishael Rosenthal e suggerito da Make42 nel suo commento .

È stato suggerito che il comportamento osservato sia correlato a HashPartitionerquando in realtà parallelizenon si mescola e non usa 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)

Spiegato:

Struttura difold per 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
}

è la stessa struttura direduce per 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"))
}

dove runJobviene eseguito senza tenere conto dell'ordine di partizione e richiede una funzione commutativa.

foldPartitione reducePartitionsono equivalenti in termini di ordine di elaborazione ed effettivamente (per eredità e delega) implementati da reduceLefte foldLeftavanti TraversableOnce.

Conclusione: foldsu RDD non può dipendere dall'ordine dei blocchi e necessita di commutatività e associatività .


Devo ammettere che l'etimologia è confusa e la letteratura sulla programmazione manca di definizioni formali. Penso che sia sicuro dire che foldon RDDs è davvero uguale a reduce, ma questo non rispetta le differenze matematiche di base (ho aggiornato la mia risposta per essere ancora più chiara). Anche se non sono d'accordo sul fatto che abbiamo davvero bisogno di commutatività, a condizione che si sia sicuri che qualunque cosa stia facendo il proprio partitore, è preservare l'ordine.
samthebest

L'ordine di piegatura non definito non è correlato al partizionamento. È una conseguenza diretta dell'implementazione di runJob.

AH! Scusa non sono riuscito a capire quale fosse il tuo punto, ma dopo aver letto il runJobcodice vedo che in effetti fa la combinazione in base a quando un'attività è terminata, NON all'ordine delle partizioni. È questo dettaglio chiave che fa sì che tutto vada a posto. Ho modificato di nuovo la mia risposta e quindi ho corretto l'errore che hai segnalato. Per favore, potresti rimuovere la tua taglia visto che ora siamo d'accordo?
samthebest

Non posso modificare o rimuovere - non esiste tale opzione. Posso premiare ma penso che tu ottenga un bel po 'di punti solo da un'attenzione, sbaglio? Se confermi di volere che ti ricompensi, lo faccio nelle prossime 24 ore. Grazie per le correzioni e scusa per il metodo, ma sembrava che tu ignorassi tutti gli avvertimenti, è una cosa importante e la risposta è stata citata ovunque.

1
Che ne dici di assegnarlo a @Mishael Rosenthal dato che è stato il primo a dichiarare chiaramente la preoccupazione. Non mi interessano i punti, semplicemente mi piace usare SO per il SEO e l'organizzazione.
samthebest

2

Un'altra differenza per Scalding è l'uso di combinatori in Hadoop.

Immagina che la tua operazione sia monoide commutativa, con riduci verrà applicato anche sul lato mappa invece di mescolare / ordinare tutti i dati ai riduttori. Con foldLeft questo non è il caso.

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

È sempre buona norma definire le proprie operazioni come monoide in Scalding.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.