Разница между сокращением и foldLeft/fold в функциональном программировании (в частности, API Scala и Scala)?

Почему Scala и фреймворки, такие как Spark и Scalding, имеют как reduce, так и foldLeft? Итак, какая разница между reduce и fold?

Ответ 1

уменьшить vs foldLeft

Большая разница, не упомянутая в любом другом ответе stackoverflow, относящемся к этой теме, заключается в том, что reduce следует предоставить коммутативный моноид, т.е. операцию, которая является коммутативной и ассоциативной. Это означает, что операция может быть распараллелена.

Это различие очень важно для Big Data/MPP/распределенных вычислений, и вся причина, по которой reduce даже существует. Сбор можно расколоть, а reduce может работать на каждом куске, тогда reduce может работать с результатами каждого куска - на самом деле уровень отсечения не должен останавливаться на одном уровне. Мы могли бы нарезать каждый кусок. Вот почему суммирование целых чисел в списке - это O (log N), если задано бесконечное количество процессоров.

Если вы просто посмотрите на подписи, нет причин для существования reduce, потому что вы можете достичь всего, что можете, с помощью reduce с помощью foldLeft. Функциональность foldLeft больше функциональности reduce.

Но вы не можете распараллелить foldLeft, поэтому его время выполнения всегда равно O (N) (даже если вы загружаете коммутативный моноид). Это связано с тем, что предполагается, что операция не является коммутативным моноидом, и поэтому кумулятивное значение будет вычисляться серией последовательных агрегаций.

foldLeft не предполагает коммутативности и ассоциативности. Это ассоциативность, которая дает возможность измельчить коллекцию, и это коммутативность, которая упрощает накопление, потому что порядок не важен (так что не имеет значения, какой порядок сводит каждый из результатов от каждого из кусков). Строго говоря, коммутативность не нужна для распараллеливания, например распределенных алгоритмов сортировки, она просто упрощает логику, потому что вам не нужно давать ваши куски заказу.

Если вы посмотрите на документацию Spark для reduce, в нем конкретно говорится "... коммутативный и ассоциативный двоичный оператор"

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

Вот доказательство того, что reduce НЕ является лишь частным случаем foldLeft

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

уменьшить vs fold

Теперь это то, где он немного приближается к FP/математическим корням, и немного сложнее объяснить. Сокращение определяется формально как часть парадигмы MapReduce, которая касается упорядоченных коллекций (мультимножеств), Fold формально определяется в терминах рекурсии (см. Катаморфизм) и, таким образом, принимает структуру/последовательность в коллекции.

В Scalding нет метода fold, потому что в рамках (строгой) модели программирования Map Reduce мы не можем определить fold, потому что куски не имеют упорядочения, а fold требуется ассоциативность, а не коммутативность.

Проще говоря, reduce работает без порядка кумуляции, fold требует порядка кумуляции, и именно этот порядок кумуляции требует нулевого значения NOT существования нулевого значения, которое их отличает. Строго говоря, reduce должен работать с пустой коллекцией, потому что ее нулевое значение может быть выведено путем принятия произвольного значения x, а затем решения x op y = x, но это не работает с некоммутативной операцией, поскольку может существовать левое и правое нулевое значение, отличные (т.е. x op y != y op x). Конечно, Scala не утруждает себя тем, что это нулевое значение, так как это потребует выполнения некоторой математики (которая, вероятно, невычислима), поэтому просто генерирует исключение.

Кажется (как это часто бывает в этимологии), что этот оригинальный математический смысл был утрачен, поскольку единственным очевидным различием в программировании является подпись. В результате reduce стал синонимом fold, вместо того, чтобы сохранить его оригинальное значение из MapReduce. Теперь эти термины часто используются взаимозаменяемо и ведут себя одинаково в большинстве реализаций (игнорируя пустые коллекции). Странность усугубляется особенностями, как в Искры, которые мы сейчас рассмотрим.

Таким образом, Spark имеет fold, но порядок, в котором суб-результаты (по одному для каждого раздела) объединены (на момент написания), является тем же самым порядком, в котором выполняются задачи, и, следовательно, не является детерминированным. Благодаря @CafeFeed, указав, что fold использует runJob, который после прочтения кода понял, что он не детерминирован. Дальнейшая путаница создается Спарком, имеющим treeReduce, но не treeFold.

Заключение

Существует разница между reduce и fold даже при применении к непустым последовательностям. Первый определяется как часть парадигмы программирования MapReduce для коллекций с произвольным порядком (http://theory.stanford.edu/~sergei/papers/soda10-mrc.pdf), и следует предположить, что операторы также являются коммутативными к тому, чтобы быть ассоциативным, чтобы дать детерминированные результаты. Последнее определяется в терминах catomorphisms и требует, чтобы коллекции имели понятие последовательности (или определены рекурсивно, как связанные списки), поэтому не требуют коммутативных операторов.

На практике из-за нематического характера программирования reduce и fold имеют тенденцию вести себя одинаково, либо правильно (например, в Scala), либо неправильно (например, в Spark).

Дополнительно: мое мнение о API-интерфейсе Spark

Мое мнение заключается в том, что путаницы можно было бы избежать, если бы использование слова fold было полностью опущено в Spark. По крайней мере, у искры есть заметка в их документации:

Это ведет себя несколько иначе, чем операции сброса, реализованные для нераспределенные коллекции в функциональных языках типа Scala.

Ответ 2

Если я не ошибаюсь, хотя API Spark не требует этого, fold также требует, чтобы f был коммутативным. Потому что порядок, в котором агрегаты будут агрегированы, не гарантируется. Например, в следующем коде сортируется только первый распечаток:

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

Распечатка:

АБВГДЕЖЗИКЛМНОПРСТУФХЧШЭЮЯ

abcghituvjklmwxyzqrsdefnop

defghinopjklmqrstuvabcwxyz

Ответ 3

Еще одно отличие для Scalding - использование комбинаторов в Hadoop.

Представьте, что ваша операция является коммутативной моноидой, а ее сокращение будет применяться на стороне карты, а не перетасовки/сортировки всех данных на редукторы. С 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 }
}

Всегда правильно определять свои операции как моноиды в Scalding.

Ответ 4

fold в Apache Spark не совпадает с fold для нераспределенных коллекций. Фактически он требует коммутативной функции для получения детерминированных результатов:

Это ведет себя несколько иначе, чем операции свертки, реализованные для нераспределенных коллекции в функциональных языках типа Scala. Эта операция сложения может быть применена к разделов индивидуально, а затем сворачивать эти результаты в конечный результат, а не применяйте сгиб к каждому элементу последовательно в некотором определенном порядке. Для функций которые не являются коммутативными, результат может отличаться от результата складывания, применяемого к нераспределенная коллекция.

Этот был показан Мишалем Розенталем и предложен Make42 в его комментарий.

Было высказано предположение, что наблюдаемое поведение связано с HashPartitioner, когда на самом деле parallelize не перемешивается и не использует 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)

Разъяснения:

Структура fold для 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
}

является тем же как структура reduce для 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"))
}

где runJob выполняется с пренебрежением порядком раздела и возникает необходимость в коммутативной функции.

foldPartition и reducePartition эквивалентны в терминах порядка обработки и эффективно (путем наследования и делегирования), реализованного reduceLeft и foldLeft на TraversableOnce.

Заключение: fold на RDD не может зависеть от порядка кусков и требует коммутативности и ассоциативности.